feat: add home screen widget to start the timer with one tap#50
Merged
Conversation
Adds a classic AppWidgetProvider (no new dependencies) in :app that shows the saved preset and starts SleepTimerService on a single tap, without opening the app. The idle tap routes through a broadcast trampoline so the duration is read fresh from DataStore at tap time instead of being baked into a stale PendingIntent; a widget tap is an exemption from the FGS background-start restrictions, same as the notification actions. The widget is dynamic in both senses: - Live state: TimerWidgetUpdater (started from SleepTimerApp) observes TimerRepository + SettingsRepository and re-renders placed widgets when the displayed minutes, phase, or preset change. While running it shows the remaining minutes and a tap cancels; during fade-out a tap cancels and restores volume. The foreground service keeps the process (and thus the collector) alive exactly while the widget could go stale. - Dynamic color: Material You palette via values-v31 overrides on Android 12+, falling back to the app's Midnight palette below, with the system widget corner radius on 31+. Strings are localized in en and de per the project rules. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DZ1LHEs6iJEtue7FHJtBLr
Halve the widget's default height: targetCellHeight 2 -> 1 (minHeight 40dp), keeping the 2-cell width, and switch the layout to a horizontal icon + two-line text arrangement so it fits a single cell of height. Add a "Home Screen Widget" settings section letting the user choose what a widget tap starts: the last used time (the persisted dial preset, default and previous behavior) or a fixed duration set with a 5-minute step slider (5-240 min). The choice lives in UserSettings (widgetUseFixedDuration / widgetFixedMinutes, clamped on read and write) following the DataStore settings pattern; the widget trampoline, the provider's onUpdate, and TimerWidgetUpdater all resolve the duration via UserSettings.widgetStartMinutes() at use time, so the idle widget always displays exactly what a tap will start. Strings localized in en and de. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DZ1LHEs6iJEtue7FHJtBLr
Each widget instance can now be configured individually — one widget for the last used time, another fixed at 60 minutes, etc. This replaces the global "Home Screen Widget" settings section from the previous commit (never released, so removed without migration): - WidgetConfig + WidgetConfigRepository (core:data) store one entry per appWidgetId in the existing DataStore file; entries are removed in the provider's onDeleted so deleted widgets don't leak keys. - SleepTimerWidgetConfigActivity (declared via android:configure) hosts the new WidgetConfigScreen from feature:timer, themed like the rest of the app. On Android 12+ placement is instant with defaults (configuration_optional) and widgets are editable via long-press (reconfigurable); older launchers open the screen on placement. The screen addresses the provider through an injected @WidgetProviderComponent ComponentName, mirroring the @DeviceAdminComponent pattern, to request the first draw after saving (hosts skip the initial APPWIDGET_UPDATE when a configure activity is declared). - Rendering, the tap trampoline, and the live updater are per-instance: the start PendingIntent carries the appWidgetId (requestCode = id, since extras don't participate in PendingIntent identity), and each idle widget shows exactly the duration its own tap will start. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DZ1LHEs6iJEtue7FHJtBLr
Replace the fixed-duration slider with the app's CircularDial + TimeDisplay, reused as-is from the timer screen — the dial component is self-contained (CircularDialState + callbacks), so no code is duplicated. The dial is seeded from the stored config and pushes only committed drag values back into the draft (same one-way sync TimerScreen uses); live drag preview is rendered directly from dialState. This also makes any 1-240 minute value selectable, matching the main screen, instead of the slider's 5-minute grid. WidgetDurationSlider and its strings are removed. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DZ1LHEs6iJEtue7FHJtBLr
Owner
Author
|
seems to work |
…g activity Addresses code-review findings on the widget PR: - Wrap the trampoline's startForegroundService in try/catch. A widget tap normally grants the FGS background-start exemption, but a background- restricted app is denied it and startForegroundService throws ForegroundServiceStartNotAllowedException (an IllegalStateException), crashing onReceive. Catch its superclass so the tap is a logged no-op. - Extract Map<Int, WidgetConfig>.startMinutesFor(id, preset) so the provider render and the live updater resolve idle minutes through one helper, keeping the "idle widget shows exactly what a tap starts" invariant in a single place. - Reject config-activity launches whose appWidgetId isn't bound to our provider. The activity is exported for launcher compatibility; the host always binds the id before launching config, so this only blocks another app invoking it with a foreign/bogus id and leaking an orphan config. - Reconcile the settings-storage rule in AGENTS.md to permit a dedicated repository over the same DataStore file for per-instance/dynamic config (WidgetConfigRepository), which the one-value-per-setting UserSettings model can't hold. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…config parsing Addresses review findings on the widget PR: - Replace runBlocking in onUpdate/onDeleted/startConfiguredTimer with goAsync() + a process-lifetime IO scope, so the broadcast callbacks no longer block the main thread on DataStore reads/writes (onDeleted's write was the worst — an fsync on the main thread). The running-timer render stays synchronous (state is in-process); only DataStore paths go async. On the tap path goAsync keeps the broadcast — and with it the widget-tap FGS background-start exemption — alive until the service is started. - Tap path resolves the widget config first and only reads presetMinutes for last-used widgets; fixed-duration widgets skip the settings read. - Add distinctUntilChanged() to the configs flow so unrelated UserSettings writes (shared DataStore file) no longer re-fire the always-on TimerWidgetUpdater. - Centralize the fixed-minutes clamp in clampFixedWidgetMinutes(), used by the configs rebuild, getConfig, and setConfig so the bound can't drift. - Parse stored configs with safe casts (as?) so a wrong-typed value skips the key instead of throwing a ClassCastException past the .catch and killing the collector. - Add a previewImage vector so pre-31 launchers (which can't render previewLayout) show a representative widget-picker thumbnail. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Fixes from the PR #50 review: - onRestored: migrate per-widget configs when the host remaps appWidgetIds on a backup/device-transfer restore. Adds WidgetConfigRepository.remapConfigs (atomic snapshot -> clear-old -> write-new). Without it, restored widgets lost their fixed-duration config, old-id entries leaked, and a reused id could inherit a foreign config. - onUpdate: re-read the timer phase after the async DataStore gap so a stale IDLE frame can't clobber the live updater's fresher running render. - Terminal idle render: SleepTimerService now fires a TimerWidgetRefresher on expiry/cancel (interface in :core:service, impl in :app) so the last widget frame is idle even if the process is reclaimed before the in-process collector renders. - Double-tap: guard startConfiguredTimer with an AtomicBoolean so a rapid second tap can't issue a second concurrent ACTION_START. - Config screen: gate on stored settings loading (like the config gate) to stop the theme flashing the default palette on open. - TimerWidgetUpdater: collapse per-second ticks to a display-minute signal before the combine, and skip the render loop when no widget is placed. - WidgetConfig: move the class KDoc back onto the class (was detached by clampFixedWidgetMinutes). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Adds a dynamic home screen widget that starts the sleep timer with a single tap, without opening the app. Widgets default to 2×1, and every placed instance can be configured individually.
SleepTimerServicewith that instance's configured duration. The tap routes through a broadcast trampoline inSleepTimerWidgetProvider(carrying theappWidgetId) so the duration is read fresh from DataStore at tap time instead of being baked into a PendingIntent that goes stale when settings change. A widget tap is a documented exemption from the FGS background-start restrictions (same mechanism the notification actions rely on).WidgetConfigentries are stored perappWidgetIdviaWidgetConfigRepository(core:data, same DataStore file) and removed inonDeleted. The config UI (WidgetConfigScreeninfeature:timer, hosted bySleepTimerWidgetConfigActivityin:appviaandroid:configure) is themed like the rest of the app. On Android 12+ placement is instant with defaults (configuration_optional) and widgets are editable via long-press → reconfigure; older launchers open the config screen on placement. The screen addresses the provider through an injected@WidgetProviderComponent ComponentName, mirroring the existing@DeviceAdminComponentpattern.TimerWidgetUpdater(started once fromSleepTimerApp) observesTimerRepository+ settings + per-widget configs and re-renders placed widgets on change (distinctUntilChanged, so RemoteViews are pushed once a minute, not once a second). While a timer runs every widget shows the remaining minutes and a tap cancels; during fade-out a tap cancels and restores volume. The foreground service keeps the process — and thus the collector — alive for exactly the window in which widgets could go stale, so the last render after process death is always the idle state.requestCode = appWidgetIdsince extras don't participate in PendingIntent identity — without it all instances would share one intent and start the same duration.system_neutral1/system_accent1) and the system widget corner radius viavalues-v31overrides on Android 12+, falling back to the app's Midnight palette (#1A1830/#C9B8FF) on older releases.AppWidgetProvider/RemoteViews, no Glance), no network, and the module dependency flow is untouched.values/andvalues-de/.Test plan
assembleDebug,lint,testDebugUnitTest🤖 Generated with Claude Code
https://claude.ai/code/session_01DZ1LHEs6iJEtue7FHJtBLr