Skip to content

feat: system widgets foundation + price widget#895

Open
jvsena42 wants to merge 48 commits intomasterfrom
feat/system-widgets-foundation
Open

feat: system widgets foundation + price widget#895
jvsena42 wants to merge 48 commits intomasterfrom
feat/system-widgets-foundation

Conversation

@jvsena42
Copy link
Copy Markdown
Member

@jvsena42 jvsena42 commented Apr 9, 2026

This PR:

  1. Adds a Bitcoin Price home screen widget using Jetpack Glance
  2. Implements independent widget preferences stored in a separate DataStore
  3. Uses WorkManager for periodic background data refresh (15-minute intervals)
  4. Includes a configuration Activity for widget settings on click
  5. Renders a sparkline chart via Bitmap using Android Canvas API

Description

Introduces the foundation for Android home screen widgets (AppWidgets) using Jetpack Glance, starting with the Price widget. The widget displays enabled trading pairs with price, change percentage, and a line chart — matching the in-app Price widget layout.

The architecture reuses the existing PriceService for data fetching (via a new fetchData(period) overload) while keeping widget preferences completely independent from the in-app widget system through a dedicated AppWidgetPreferencesStore backed by its own DataStore file.

A Glance design system (GlanceTextStyles, GlanceColors) ports the app's typography and color tokens to Glance equivalents, and shared components (GlanceWidgetScaffold, GlanceDataRow) provide consistent widget layouts.

Preview

Screen_recording_20260421_153032.webm

QA Notes

  1. Long-press the home screen and select "Widgets"
    • Find "Bitcoin Price" in the widget picker
    • Verify the preview shows a price layout with mock data
  2. Add the widget to the home screen
    • Verify the configuration screen opens with trading pair and period options
    • Toggle pairs and period, tap Save
    • Verify the widget updates to show selected pairs with price, change %, and sparkline chart
  3. Wait 15+ minutes or force-stop and reopen
    • Verify the widget refreshes data via WorkManager
  4. Tap the widget
    • Verify it opens the widget settings screen
    • Verify the widget update after saving new preferences
  5. Long-press the widget
    • Verify the widget is resizable
    • Verify the chat is hidden if not enough vertical space
  6. Verify the OS widget settings are independent from internal app widgets

@jvsena42 jvsena42 changed the title feat: add price home screen widget feat: System widgets foundation + price widget Apr 9, 2026
@jvsena42 jvsena42 self-assigned this Apr 10, 2026
@jvsena42 jvsena42 changed the title feat: System widgets foundation + price widget feat: system widgets foundation + price widget Apr 21, 2026
@jvsena42 jvsena42 marked this pull request as ready for review April 21, 2026 18:18
Copy link
Copy Markdown
Contributor

@claude claude Bot left a comment

Choose a reason for hiding this comment

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

test

Copy link
Copy Markdown
Contributor

@claude claude Bot left a comment

Choose a reason for hiding this comment

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

test body only - inline comments follow in separate calls

Copy link
Copy Markdown
Contributor

@claude claude Bot left a comment

Choose a reason for hiding this comment

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

test

Copy link
Copy Markdown
Contributor

@claude claude Bot left a comment

Choose a reason for hiding this comment

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

Code review

Found several issues — see inline comments for details.

Additionally: CHANGELOG.md is missing a required entry. Per CLAUDE.md: ALWAYS add exactly ONE entry per PR under ## [Unreleased] in CHANGELOG.md for feat: and fix: PRs. This is a feat: PR with no CHANGELOG.md changes in the diff.

Comment on lines +176 to +183
<activity
android:name=".appwidget.config.AppWidgetConfigActivity"
android:exported="true"
android:excludeFromRecents="true"
android:launchMode="singleInstance"
android:screenOrientation="portrait"
android:taskAffinity=""
android:theme="@style/Theme.App">
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: singleInstance launch mode silently breaks setResult() — first-time widget placement will always be cancelled

singleInstance (and singleTask) activities live in their own task and cannot return a result to the calling activity — setResult() is silently ignored. The Android launcher starts widget configure activities via startActivityForResult with APPWIDGET_CONFIGURE and relies on RESULT_OK + EXTRA_APPWIDGET_ID in the response to finalize widget placement. With singleInstance, the launcher receives RESULT_CANCELED immediately and cancels the placement.

android:taskAffinity="" compounds the issue by forcing the activity into a separate isolated task with no affinity back to the caller.

Fix: Remove android:launchMode="singleInstance" and android:taskAffinity="". The default launch mode (standard) is correct for configure activities — they are started per widget instance and must be able to return a result.

See AppWidgetConfigActivity.kt#L38-L50 for the setResult(RESULT_OK, ...) calls that will be silently dropped.

Comment on lines +38 to +44
when (type) {
AppWidgetType.PRICE -> dataRepository.fetchPriceData()
.onSuccess {
preferencesStore.cachePriceData(it)
PriceGlanceWidget().updateAll(appContext)
}
.onFailure { Logger.warn("Failed to refresh price", e = 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.

Bug: Worker ignores each widget's configured GraphPeriod — periodic refreshes overwrite the cache with the wrong period

dataRepository.fetchPriceData() (no-arg overload) delegates to priceService.fetchData() which reads the period from the in-app widget store — not from each home screen widget's HomePricePreferences.period. Every 15-minute background refresh overwrites the single global cachedPrice with the in-app period regardless of what any individual home screen widget user has configured.

The initial fetch in saveAndFinish correctly uses pricePreferences.period, but all subsequent periodic refreshes ignore it.

Fix: Iterate over active widget entries and fetch using entry.pricePreferences.period. Since cachedPrice is a single PriceDTO? shared across all entries, the cache structure also needs to support per-period or per-entry data to avoid conflicts.


CLAUDE.md violation (line 44): NEVER add e = named parameter to Logger calls

Per CLAUDE.md.

Suggested change
when (type) {
AppWidgetType.PRICE -> dataRepository.fetchPriceData()
.onSuccess {
preferencesStore.cachePriceData(it)
PriceGlanceWidget().updateAll(appContext)
}
.onFailure { Logger.warn("Failed to refresh price", e = it, context = TAG) }
.onFailure { Logger.warn("Failed to refresh price", it, context = TAG) }

Comment on lines +51 to +63
companion object {
private const val TAG = "AppWidgetRefreshWorker"
private const val WORK_NAME = "appwidget_refresh"

fun enqueue(context: Context) {
val constraints = Constraints.Builder()
.setRequiredNetworkType(NetworkType.CONNECTED)
.build()

val request = PeriodicWorkRequestBuilder<AppWidgetRefreshWorker>(
repeatInterval = 15,
repeatIntervalTimeUnit = TimeUnit.MINUTES,
).setConstraints(constraints).build()
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 violations: companion object placement + TimeUnit instead of Kotlin Duration

1. companion object placement (CLAUDE.md: ALWAYS position companion object at the top of the class)

The companion object is declared after doWork(). It must be the first member in the class body.

2. TimeUnit instead of Kotlin Duration (CLAUDE.md: ALWAYS prefer Kotlin Duration for timeouts and delays)

PeriodicWorkRequestBuilder has an overload accepting kotlin.time.Duration. Replace with:

val request = PeriodicWorkRequestBuilder<AppWidgetRefreshWorker>(15.minutes)
    .setConstraints(constraints)
    .build()

Also swap import java.util.concurrent.TimeUnit for import kotlin.time.Duration.Companion.minutes.

Comment on lines +72 to +80
fun cancelIfNoWidgets(context: Context) {
val manager = AppWidgetManager.getInstance(context)
val hasAny = manager.getAppWidgetIds(
ComponentName(context, PriceGlanceReceiver::class.java),
).isNotEmpty()
if (!hasAny) {
WorkManager.getInstance(context).cancelUniqueWork(WORK_NAME)
}
}
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.

Foundation flaw: cancelIfNoWidgets only checks PriceGlanceReceiver — will cancel refresh for all widget types once a second type is added

This PR introduces the widgets foundation for future widget types, but the shared WORK_NAME = "appwidget_refresh" worker is cancelled solely based on whether PriceGlanceReceiver has any widgets. When a second widget type is added and a user removes all Price widgets, PriceGlanceReceiver.onDisabled will cancel the shared refresh worker even if widgets of other types are still active.

Fix: Check all registered widget types before cancelling — e.g. maintain an AppWidgetTypeKClass<out GlanceAppWidgetReceiver> mapping and verify every type has zero active instances before calling cancelUniqueWork.

Comment on lines +16 to +19
override fun onDisabled(context: Context) {
super.onDisabled(context)
AppWidgetRefreshWorker.cancelIfNoWidgets(context)
}
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: Missing onDeleted override — deleted widget instances leave orphaned preferences

onDeleted(context, appWidgetIds) fires when individual widget instances are removed; onDisabled only fires when the last instance is removed. Without overriding onDeleted, AppWidgetPreferencesStore.unregisterWidget() is never called for per-instance deletions, so stale AppWidgetEntry rows accumulate in the DataStore indefinitely.

When Android later reuses the same appWidgetId for a freshly placed widget, registerWidget's early-return guard means the new widget silently inherits the deleted widget's pricePreferences. Note: unregisterWidget is defined but has no callers in this PR.

Fix: Override onDeleted and call unregisterWidget for each deleted ID. Since it is suspend, use goAsync() to extend the broadcast lifecycle:

override fun onDeleted(context: Context, appWidgetIds: IntArray) {
    super.onDeleted(context, appWidgetIds)
    val result = goAsync()
    val store = AppWidgetPreferencesStore.getInstance(context)
    CoroutineScope(Dispatchers.IO).launch {
        appWidgetIds.forEach { store.unregisterWidget(it) }
        result.finish()
    }
}

Comment on lines +14 to +20
override suspend fun readFrom(input: InputStream): AppWidgetData {
return try {
json.decodeFromString(input.readBytes().decodeToString())
} catch (e: SerializationException) {
Logger.error("Failed to deserialize: $e")
defaultValue
}
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 violations: try-catch instead of runCatching, $e interpolated in log message, missing context = TAG

Three rules from CLAUDE.md are violated here:

  1. ALWAYS use the Result API instead of try-catch — replace with runCatching { }.getOrElse { }.
  2. NEVER manually append the Throwable's message"Failed to deserialize: $e" interpolates the exception directly into the string. Pass the throwable positionally to Logger instead.
  3. ALWAYS pass the TAG as context to Logger callscontext = TAG is missing. Since this is an object, add private const val TAG = "AppWidgetDataSerializer" at the top of the object body.

Suggested fix for readFrom:

override suspend fun readFrom(input: InputStream): AppWidgetData {
    return runCatching {
        json.decodeFromString(input.readBytes().decodeToString())
    }.getOrElse {
        Logger.error("Failed to deserialize AppWidgetData", it, context = TAG)
        defaultValue
    }
}

}
dataRepository.fetchPriceData(pricePreferences.period ?: GraphPeriod.ONE_DAY)
.onSuccess { preferencesStore.cachePriceData(it) }
.onFailure { Logger.warn("Failed to fetch initial price data", e = 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 violation: NEVER add e = named parameter to Logger calls

Per CLAUDE.md.

Suggested change
.onFailure { Logger.warn("Failed to fetch initial price data", e = it, context = TAG) }
.onFailure { Logger.warn("Failed to fetch initial price data", it, context = TAG) }

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