Skip to content

feat: headlines widget v61 + OS widget#919

Open
jvsena42 wants to merge 13 commits intofeat/price-widget-v61from
feat/headlines-v61
Open

feat: headlines widget v61 + OS widget#919
jvsena42 wants to merge 13 commits intofeat/price-widget-v61from
feat/headlines-v61

Conversation

@jvsena42
Copy link
Copy Markdown
Member

@jvsena42 jvsena42 commented Apr 29, 2026

FIGMA

Stacks on top of #914. This PR:

  1. Adds a Glance-backed Headlines home screen widget with Wide and Compact layouts
  2. Rebuilds the Headlines preview screen to match the v61 carousel/settings layout used by the price widget
  3. Restructures the Headlines edit screen with sectioned settings (Title / Source / Time)
  4. Adapts the in-app HeadlineCard to the v61 design (drops the widget-title row and source label)
  5. Wires Headlines preferences through the shared AppWidgetConfigActivity / preferences store / refresh worker

Description

The Headlines widget now ships as a system widget. HeadlinesGlanceWidget is registered via HeadlinesGlanceReceiver in the manifest, uses SizeMode.Responsive to switch between a Wide layout (343x152dp, source/time row under the headline) and a Compact layout (163x192dp, headline-only with optional time pinned bottom-right), and reuses the foundation's GlanceWidgetScaffold + tap-to-edit intent. Tapping the widget opens the article URL when present, falling back to AppWidgetConfigActivity for unconfigured/no-data instances. The Glance content reads cached articles from AppWidgetPreferencesStore and renders a random one.

The shared AppWidgetConfigViewModel and AppWidgetConfigScreen were extended to handle Headlines: a HeadlinePreferences field (in-app) mapped to/from HomeHeadlinePreferences (datastore), toggleShowTime / toggleShowSource actions, branched resetPreferences / saveAndFinish per AppWidgetType, and a CONTENT section listing Title / Source / Time toggles. Save now persists the entry, registers the widget, and warms the article cache via AppWidgetDataRepository.fetchArticles(). AppWidgetRefreshWorker was updated to refresh Headlines instances alongside Price.

In-app, HeadlineCard is split into wide and compact variants. Wide: full-width card with a 4-line Title, plus a Brand-colored source / White64 time row. Compact: 163x192dp card with a 4-line title and optional time at the bottom. The widget title row, newspaper icon, and "Source" label have all been removed in favor of the v61 layout. HeadlinesPreviewScreen mirrors the price preview — top-bar title, description, divider, Widget Settings row showing Default/Custom, WidgetSizeCarousel between the small and wide cards, and Save/Delete buttons. HeadlinesEditScreen is restructured into a CONTENT section using Caption13Up headers and toggle rows (Title is locked-on, Source and Time are user-toggleable) with dividers, replacing the previous flat list.

Manifest, XML widget info (appwidget_info_headlines.xml), and a static preview layout (appwidget_preview_headlines.xml) are added to register the new provider with a 4x2 default placement and 2x2 minimum, matching the price widget. Three new strings (widgets__widget__content, widgets__widget__save, widgets__widget__settings) were added to strings.xml.

Preview

os-widget.webm
app-widget.webm

QA Notes

System widget

  1. Long-press the home screen → Widgets → find "Bitcoin Headlines"
    • Verify the picker preview shows the wide layout (headline + source in Brand orange + time in White64)
  2. Drop the widget at the default 4x2 placement
    • Verify it lands on the wide layout (4-line headline title + source/time row)
  3. Resize down to 2x2
    • Verify it switches to the compact layout (4-line headline only, time pinned bottom-right when enabled)
  4. Tap the widget while it's populated
    • Verify the article URL opens in the browser
  5. Tap a freshly-added widget before any data is cached
    • Verify the loading caption shows briefly, then a real article appears once the worker finishes
  6. Open the system config screen (long-press → Edit / tap on a fresh add)
    • Verify the title is "Bitcoin Headlines" and the CONTENT section lists Title / Source / Time
    • Verify Title is locked on
    • Verify Source / Time toggles flip between Brand and White50 and persist on Save

In-app widget

  1. Open Widgets feed → Bitcoin Headlines
    • Verify the preview screen shows the v61 layout: top-bar "Bitcoin Headlines" title, description, divider, Widget Settings row, size carousel, Save Widget button
    • Verify the Widget Settings value reads "Default" with default prefs and "Custom" once Source or Time is toggled off
  2. Swipe the carousel
    • Verify the small and wide cards render correctly with the same article and the size label flips between Small / Wide
  3. Tap Widget Settings → HeadlinesEditScreen
    • Verify the CONTENT section header is uppercase and rendered with Caption13Up
    • Verify Title row is non-interactive, Source and Time rows toggle independently
    • Verify the article preview at the top reflects toggle state
  4. Save and verify the in-app HeadlineCard on Home reflects the new prefs (no source row when Source is off, no time when Time is off)
  5. With the widget already added, reopen the preview screen
    • Verify both Delete and Save buttons appear and Delete removes the in-app card

Build & lint

  • ./gradlew compileDevDebugKotlin clean
  • ./gradlew testDevDebugUnitTest clean (existing HeadlineCardTest / HeadlinesPreviewContentTest updated for the new layout)
  • ./gradlew detekt clean

@jvsena42 jvsena42 changed the title feat: implement headlines os widget v61 feat: implement headline widget v61 + OS widget Apr 29, 2026
@jvsena42 jvsena42 self-assigned this Apr 29, 2026
@jvsena42 jvsena42 changed the title feat: implement headline widget v61 + OS widget feat: headlines widget v61 + OS widget Apr 29, 2026
@jvsena42 jvsena42 marked this pull request as ready for review April 29, 2026 14:21
private fun ToggleRow(
content: @Composable RowScope.() -> Unit,
isEnabled: Boolean,
onToggle: () -> Unit,
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

CLAUDE.md: ToggleRow composable is missing modifier: Modifier = Modifier parameter

@Composable
private fun ToggleRow(
content: @Composable RowScope.() -> Unit,
isEnabled: Boolean,
onToggle: () -> Unit,
toggleEnabled: Boolean = true,
) {
Row(

ToggleRow is a @Composable function but has no modifier parameter at all. toggleEnabled: Boolean = true is the only optional parameter, but modifier: Modifier = Modifier must come before it as the first optional parameter.

Rule: CLAUDE.md"ALWAYS declare modifier: Modifier = Modifier, as the FIRST optional parameter in composable declarations"

Suggested change
onToggle: () -> Unit,
private fun ToggleRow(
content: @Composable RowScope.() -> Unit,
isEnabled: Boolean,
onToggle: () -> Unit,
modifier: Modifier = Modifier,
toggleEnabled: Boolean = true,
)

dataRepository.fetchArticles()
.onSuccess { preferencesStore.cacheArticles(it) }
.onFailure {
Logger.warn("Failed to refresh headlines", it, context = TAG)
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

CLAUDE.md: Duplicate error logging

dataRepository.fetchArticles()
.onSuccess { preferencesStore.cacheArticles(it) }
.onFailure {
Logger.warn("Failed to refresh headlines", it, context = TAG)
}

dataRepository.fetchArticles() delegates to newsService.fetchData(), which already has an internal .onFailure { Logger.warn("Failed to fetch news", ...) }. This .onFailure here (and the equivalent one in AppWidgetConfigViewModel.saveHeadlines at line 127) causes every failure to be logged twice.

Rule: CLAUDE.md"NEVER duplicate error logging in .onFailure {} if the called method already logs the same error internally"

Remove the .onFailure logging from both call sites, or remove the logging inside NewsService.fetchData() and keep it at the callers.

.background(Colors.White10)
.clickableAlpha {
val intent = Intent(Intent.ACTION_VIEW, link.toUri())
context.startActivity(intent)
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bug: HeadlineCardSmall calls startActivity without empty-link guard

.clickableAlpha {
val intent = Intent(Intent.ACTION_VIEW, link.toUri())
context.startActivity(intent)
}
) {

If link is empty or has no scheme (possible when the news API returns an article with an empty link), calling startActivity(Intent(ACTION_VIEW, "".toUri())) throws ActivityNotFoundException and crashes. Notably, the new HeadlinesGlanceContent in this same PR correctly guards:

val tapIntent = if (article != null && article.link.isNotEmpty()) {
    Intent(Intent.ACTION_VIEW, article.link.toUri())...
} else {
    Intent(context, AppWidgetConfigActivity::class.java)...
}

Apply the same guard here. (The same issue exists in the pre-existing HeadlineCard wide variant, but HeadlineCardSmall is new code introduced by this PR.)

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant