Skip to content

feat: add home screen widget to start the timer with one tap#50

Merged
Xitee1 merged 7 commits into
mainfrom
claude/android-home-widget-qbs9qh
Jul 17, 2026
Merged

feat: add home screen widget to start the timer with one tap#50
Xitee1 merged 7 commits into
mainfrom
claude/android-home-widget-qbs9qh

Conversation

@Xitee1

@Xitee1 Xitee1 commented Jul 14, 2026

Copy link
Copy Markdown
Owner

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.

  • One-tap start: tapping the idle widget starts SleepTimerService with that instance's configured duration. The tap routes through a broadcast trampoline in SleepTimerWidgetProvider (carrying the appWidgetId) 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).
  • Per-widget configuration: each instance starts either the last used time (the persisted dial preset — default) or a fixed duration (5–240 min slider). WidgetConfig entries are stored per appWidgetId via WidgetConfigRepository (core:data, same DataStore file) and removed in onDeleted. The config UI (WidgetConfigScreen in feature:timer, hosted by SleepTimerWidgetConfigActivity in :app via android: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 @DeviceAdminComponent pattern.
  • Live state: TimerWidgetUpdater (started once from SleepTimerApp) observes TimerRepository + 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.
  • Per-instance PendingIntents: the start intent uses requestCode = appWidgetId since extras don't participate in PendingIntent identity — without it all instances would share one intent and start the same duration.
  • Dynamic color: Material You palette (system_neutral1/system_accent1) and the system widget corner radius via values-v31 overrides on Android 12+, falling back to the app's Midnight palette (#1A1830 / #C9B8FF) on older releases.
  • No new dependencies (classic AppWidgetProvider/RemoteViews, no Glance), no network, and the module dependency flow is untouched.
  • Stale-tap safety: a start tap landing while a timer is already active is ignored by the trampoline; a cancel tap landing after the timer ended is absorbed by the service's existing no-active-countdown guard.
  • Strings localized in values/ and values-de/.

Test plan

  • CI: assembleDebug, lint, testDebugUnitTest
  • Manual: add widget → (12+) placed instantly with "last used" default, long-press → edit opens config; (pre-12) config opens on placement, back cancels placement
  • Manual: two widgets with different fixed durations each start their own time; "last used" widget follows the dial preset
  • Manual: tap starts timer with notification; all widgets count down; tap while running cancels; widgets render correctly after reboot/process death (idle state); deleting a widget removes its stored config

🤖 Generated with Claude Code

https://claude.ai/code/session_01DZ1LHEs6iJEtue7FHJtBLr

claude added 4 commits July 14, 2026 17:59
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
@Xitee1

Xitee1 commented Jul 14, 2026

Copy link
Copy Markdown
Owner Author

seems to work

Xitee1 and others added 3 commits July 17, 2026 22:33
…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>
@Xitee1
Xitee1 merged commit 7fb774d into main Jul 17, 2026
1 check passed
@Xitee1
Xitee1 deleted the claude/android-home-widget-qbs9qh branch July 17, 2026 21:36
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.

2 participants