From 82fff19d3d05ca171c9a03b8e57549b7c56a1517 Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Fri, 2 Oct 2026 18:01:11 +0200 Subject: [PATCH] =?UTF-8?q?feat(widgets):=20M3=20Expressive=20widget=20lin?= =?UTF-8?q?eup=20=E2=80=94=20analog,=20digital,=20stopwatch,=20quick=20tim?= =?UTF-8?q?er?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Six widgets, each with minimal settings, styled with M3 Expressive shapes and Material You colours, and sized proportionally to the placed widget (SizeMode.Exact + LocalSize): - Analog clock (new): nine faces on MaterialShapes dials (Round, Numeral, Day, Cookie, Sunny, Scallop, Clover, Square, Petal) with per-face hands, a seconds dot and Google-Clock-style orbiting date on some faces, and a day/date pill on others. Style picker previews inflate the real views. - Digital clock: rebuilt as RemoteViews faces (Classic, Thin, Bold, Pill, Stacked) shared by the widget and its config previews; world clocks are opt-in and only on the card faces. - Next alarm and Timers: shape badges, icon buttons, proportional readouts; a lone timer on a tall widget gets a stacked layout. - Stopwatch (new): large readout with play/pause pill and lap/reset. - Quick timer (new): a Scallop that is a timer of its own — tap to start a transient timer of its configured duration, tap again to reset/stop. Colours resolve in the launcher (widget_* palette resources, GlanceTheme's dynamic colours) so widgets follow day/night and the wallpaper. Data widgets collect their flows inside the composition so a live Glance session never shows stale state, chronometer bases use elapsedRealtime, and config activities close cleanly on an invalid widget id. --- app/build.gradle.kts | 1 + app/src/main/AndroidManifest.xml | 95 +++++- .../domain/widget/AnalogClockSettings.kt | 13 + .../domain/widget/ClockWidgetSettings.kt | 29 ++ .../domain/widget/QuickTimerWidgetSettings.kt | 42 +++ .../domain/widget/StopwatchWidgetModel.kt | 22 ++ .../clockula/timer/QuickTimerIntents.kt | 12 + .../timer/receiver/QuickTimerReceiver.kt | 66 ++++ .../ui/widget/AnalogClockWidgetContent.kt | 246 +++++++++++++++ .../clockula/ui/widget/ClockWidgetContent.kt | 116 ++++--- .../clockula/ui/widget/DigitalFaces.kt | 158 ++++++++++ .../ui/widget/NextAlarmWidgetContent.kt | 119 ++++++-- .../ui/widget/QuickTimerWidgetContent.kt | 174 +++++++++++ .../ui/widget/StopwatchWidgetContent.kt | 188 ++++++++++++ .../clockula/ui/widget/TimerWidgetContent.kt | 286 +++++++++++++----- .../clockula/ui/widget/WidgetDefaults.kt | 29 -- .../clockula/ui/widget/WidgetKit.kt | 185 +++++++++++ .../config/AnalogClockConfigActivity.kt | 142 +++++++++ .../config/ClockWidgetConfigActivity.kt | 232 ++++++++++++++ .../widget/config/QuickTimerConfigActivity.kt | 105 +++++++ .../ui/widget/config/WidgetStylePicker.kt | 66 ++++ .../clockula/widget/AnalogClockWidget.kt | 37 +++ .../clockula/widget/ClockWidget.kt | 37 ++- .../clockula/widget/ClockWidgetState.kt | 43 +++ .../clockula/widget/NextAlarmWidget.kt | 17 +- .../clockula/widget/QuickTimerWidget.kt | 36 +++ .../clockula/widget/StopwatchWidget.kt | 31 ++ .../clockula/widget/TimerWidget.kt | 18 +- .../clockula/widget/WidgetSync.kt | 26 +- .../clockula/widget/di/WidgetEntryPoint.kt | 2 + .../drawable/widget_analog_dial_clover.xml | 19 ++ .../drawable/widget_analog_dial_cookie.xml | 15 + .../res/drawable/widget_analog_dial_none.xml | 15 + .../res/drawable/widget_analog_dial_petal.xml | 15 + .../res/drawable/widget_analog_dial_round.xml | 15 + .../drawable/widget_analog_dial_scallop.xml | 15 + .../drawable/widget_analog_dial_square.xml | 15 + .../res/drawable/widget_analog_dial_sunny.xml | 15 + .../res/drawable/widget_analog_hour_bold.xml | 13 + .../res/drawable/widget_analog_hour_hand.xml | 22 ++ .../drawable/widget_analog_hour_lollipop.xml | 16 + .../res/drawable/widget_analog_hour_slim.xml | 13 + .../drawable/widget_analog_minute_bold.xml | 12 + .../drawable/widget_analog_minute_hand.xml | 17 ++ .../widget_analog_minute_lollipop.xml | 19 ++ .../drawable/widget_analog_minute_slim.xml | 21 ++ .../res/drawable/widget_analog_second_dot.xml | 14 + .../widget_analog_second_dot_bold.xml | 13 + .../widget_analog_second_dot_dated.xml | 15 + .../widget_analog_second_dot_petal.xml | 14 + .../widget_analog_second_dot_scallop.xml | 15 + .../drawable/widget_badge_pill_background.xml | 8 + app/src/main/res/drawable/widget_bg_card.xml | 9 + .../main/res/drawable/widget_bg_circle.xml | 6 + app/src/main/res/drawable/widget_bg_pill.xml | 7 + .../drawable/widget_digital_card_primary.xml | 9 + .../drawable/widget_digital_card_surface.xml | 9 + .../drawable/widget_digital_pill_primary.xml | 9 + .../drawable/widget_digital_pill_tertiary.xml | 9 + .../drawable/widget_digital_shape_square.xml | 13 + app/src/main/res/drawable/widget_ic_add.xml | 11 + app/src/main/res/drawable/widget_ic_alarm.xml | 11 + .../main/res/drawable/widget_ic_alarm_off.xml | 11 + app/src/main/res/drawable/widget_ic_flag.xml | 11 + app/src/main/res/drawable/widget_ic_pause.xml | 11 + app/src/main/res/drawable/widget_ic_play.xml | 11 + .../main/res/drawable/widget_ic_replay.xml | 11 + app/src/main/res/drawable/widget_ic_stop.xml | 11 + .../main/res/drawable/widget_shape_cookie.xml | 12 + .../res/drawable/widget_shape_scallop.xml | 12 + .../main/res/drawable/widget_shape_square.xml | 12 + .../main/res/layout/widget_analog_badge.xml | 66 ++++ .../main/res/layout/widget_analog_numeral.xml | 51 ++++ .../main/res/layout/widget_analog_plain.xml | 42 +++ .../main/res/layout/widget_chronometer.xml | 14 +- .../layout/widget_chronometer_on_primary.xml | 15 + .../main/res/layout/widget_digital_bold.xml | 42 +++ .../main/res/layout/widget_digital_city.xml | 32 ++ .../layout/widget_digital_city_on_primary.xml | 32 ++ .../res/layout/widget_digital_classic.xml | 42 +++ .../main/res/layout/widget_digital_pill.xml | 44 +++ .../res/layout/widget_digital_stacked.xml | 55 ++++ .../main/res/layout/widget_digital_thin.xml | 42 +++ app/src/main/res/layout/widget_readout.xml | 17 ++ app/src/main/res/layout/widget_text_clock.xml | 13 - app/src/main/res/values-night-v31/colors.xml | 24 ++ app/src/main/res/values-night/colors.xml | 26 ++ app/src/main/res/values-v31/colors.xml | 28 ++ app/src/main/res/values-v31/dimens.xml | 4 + app/src/main/res/values/colors.xml | 33 ++ app/src/main/res/values/dimens.xml | 4 + app/src/main/res/values/strings.xml | 36 ++- app/src/main/res/values/themes.xml | 12 + app/src/main/res/values/widget_shapes.xml | 20 ++ .../main/res/xml/widget_analog_clock_info.xml | 15 + app/src/main/res/xml/widget_clock_info.xml | 6 +- .../main/res/xml/widget_quick_timer_info.xml | 15 + .../main/res/xml/widget_stopwatch_info.xml | 13 + .../clockula/ManifestRulesTest.kt | 61 +++- .../domain/widget/ClockWidgetSettingsTest.kt | 65 ++++ .../widget/QuickTimerWidgetModelTest.kt | 47 +++ .../domain/widget/StopwatchWidgetModelTest.kt | 51 ++++ floret-kit | 2 +- 103 files changed, 3793 insertions(+), 280 deletions(-) create mode 100644 app/src/main/java/de/jeanlucmakiola/clockula/domain/widget/AnalogClockSettings.kt create mode 100644 app/src/main/java/de/jeanlucmakiola/clockula/domain/widget/ClockWidgetSettings.kt create mode 100644 app/src/main/java/de/jeanlucmakiola/clockula/domain/widget/QuickTimerWidgetSettings.kt create mode 100644 app/src/main/java/de/jeanlucmakiola/clockula/domain/widget/StopwatchWidgetModel.kt create mode 100644 app/src/main/java/de/jeanlucmakiola/clockula/timer/QuickTimerIntents.kt create mode 100644 app/src/main/java/de/jeanlucmakiola/clockula/timer/receiver/QuickTimerReceiver.kt create mode 100644 app/src/main/java/de/jeanlucmakiola/clockula/ui/widget/AnalogClockWidgetContent.kt create mode 100644 app/src/main/java/de/jeanlucmakiola/clockula/ui/widget/DigitalFaces.kt create mode 100644 app/src/main/java/de/jeanlucmakiola/clockula/ui/widget/QuickTimerWidgetContent.kt create mode 100644 app/src/main/java/de/jeanlucmakiola/clockula/ui/widget/StopwatchWidgetContent.kt delete mode 100644 app/src/main/java/de/jeanlucmakiola/clockula/ui/widget/WidgetDefaults.kt create mode 100644 app/src/main/java/de/jeanlucmakiola/clockula/ui/widget/WidgetKit.kt create mode 100644 app/src/main/java/de/jeanlucmakiola/clockula/ui/widget/config/AnalogClockConfigActivity.kt create mode 100644 app/src/main/java/de/jeanlucmakiola/clockula/ui/widget/config/ClockWidgetConfigActivity.kt create mode 100644 app/src/main/java/de/jeanlucmakiola/clockula/ui/widget/config/QuickTimerConfigActivity.kt create mode 100644 app/src/main/java/de/jeanlucmakiola/clockula/ui/widget/config/WidgetStylePicker.kt create mode 100644 app/src/main/java/de/jeanlucmakiola/clockula/widget/AnalogClockWidget.kt create mode 100644 app/src/main/java/de/jeanlucmakiola/clockula/widget/ClockWidgetState.kt create mode 100644 app/src/main/java/de/jeanlucmakiola/clockula/widget/QuickTimerWidget.kt create mode 100644 app/src/main/java/de/jeanlucmakiola/clockula/widget/StopwatchWidget.kt create mode 100644 app/src/main/res/drawable/widget_analog_dial_clover.xml create mode 100644 app/src/main/res/drawable/widget_analog_dial_cookie.xml create mode 100644 app/src/main/res/drawable/widget_analog_dial_none.xml create mode 100644 app/src/main/res/drawable/widget_analog_dial_petal.xml create mode 100644 app/src/main/res/drawable/widget_analog_dial_round.xml create mode 100644 app/src/main/res/drawable/widget_analog_dial_scallop.xml create mode 100644 app/src/main/res/drawable/widget_analog_dial_square.xml create mode 100644 app/src/main/res/drawable/widget_analog_dial_sunny.xml create mode 100644 app/src/main/res/drawable/widget_analog_hour_bold.xml create mode 100644 app/src/main/res/drawable/widget_analog_hour_hand.xml create mode 100644 app/src/main/res/drawable/widget_analog_hour_lollipop.xml create mode 100644 app/src/main/res/drawable/widget_analog_hour_slim.xml create mode 100644 app/src/main/res/drawable/widget_analog_minute_bold.xml create mode 100644 app/src/main/res/drawable/widget_analog_minute_hand.xml create mode 100644 app/src/main/res/drawable/widget_analog_minute_lollipop.xml create mode 100644 app/src/main/res/drawable/widget_analog_minute_slim.xml create mode 100644 app/src/main/res/drawable/widget_analog_second_dot.xml create mode 100644 app/src/main/res/drawable/widget_analog_second_dot_bold.xml create mode 100644 app/src/main/res/drawable/widget_analog_second_dot_dated.xml create mode 100644 app/src/main/res/drawable/widget_analog_second_dot_petal.xml create mode 100644 app/src/main/res/drawable/widget_analog_second_dot_scallop.xml create mode 100644 app/src/main/res/drawable/widget_badge_pill_background.xml create mode 100644 app/src/main/res/drawable/widget_bg_card.xml create mode 100644 app/src/main/res/drawable/widget_bg_circle.xml create mode 100644 app/src/main/res/drawable/widget_bg_pill.xml create mode 100644 app/src/main/res/drawable/widget_digital_card_primary.xml create mode 100644 app/src/main/res/drawable/widget_digital_card_surface.xml create mode 100644 app/src/main/res/drawable/widget_digital_pill_primary.xml create mode 100644 app/src/main/res/drawable/widget_digital_pill_tertiary.xml create mode 100644 app/src/main/res/drawable/widget_digital_shape_square.xml create mode 100644 app/src/main/res/drawable/widget_ic_add.xml create mode 100644 app/src/main/res/drawable/widget_ic_alarm.xml create mode 100644 app/src/main/res/drawable/widget_ic_alarm_off.xml create mode 100644 app/src/main/res/drawable/widget_ic_flag.xml create mode 100644 app/src/main/res/drawable/widget_ic_pause.xml create mode 100644 app/src/main/res/drawable/widget_ic_play.xml create mode 100644 app/src/main/res/drawable/widget_ic_replay.xml create mode 100644 app/src/main/res/drawable/widget_ic_stop.xml create mode 100644 app/src/main/res/drawable/widget_shape_cookie.xml create mode 100644 app/src/main/res/drawable/widget_shape_scallop.xml create mode 100644 app/src/main/res/drawable/widget_shape_square.xml create mode 100644 app/src/main/res/layout/widget_analog_badge.xml create mode 100644 app/src/main/res/layout/widget_analog_numeral.xml create mode 100644 app/src/main/res/layout/widget_analog_plain.xml create mode 100644 app/src/main/res/layout/widget_chronometer_on_primary.xml create mode 100644 app/src/main/res/layout/widget_digital_bold.xml create mode 100644 app/src/main/res/layout/widget_digital_city.xml create mode 100644 app/src/main/res/layout/widget_digital_city_on_primary.xml create mode 100644 app/src/main/res/layout/widget_digital_classic.xml create mode 100644 app/src/main/res/layout/widget_digital_pill.xml create mode 100644 app/src/main/res/layout/widget_digital_stacked.xml create mode 100644 app/src/main/res/layout/widget_digital_thin.xml create mode 100644 app/src/main/res/layout/widget_readout.xml delete mode 100644 app/src/main/res/layout/widget_text_clock.xml create mode 100644 app/src/main/res/values-night-v31/colors.xml create mode 100644 app/src/main/res/values-night/colors.xml create mode 100644 app/src/main/res/values-v31/colors.xml create mode 100644 app/src/main/res/values-v31/dimens.xml create mode 100644 app/src/main/res/values/dimens.xml create mode 100644 app/src/main/res/values/widget_shapes.xml create mode 100644 app/src/main/res/xml/widget_analog_clock_info.xml create mode 100644 app/src/main/res/xml/widget_quick_timer_info.xml create mode 100644 app/src/main/res/xml/widget_stopwatch_info.xml create mode 100644 app/src/test/java/de/jeanlucmakiola/clockula/domain/widget/ClockWidgetSettingsTest.kt create mode 100644 app/src/test/java/de/jeanlucmakiola/clockula/domain/widget/QuickTimerWidgetModelTest.kt create mode 100644 app/src/test/java/de/jeanlucmakiola/clockula/domain/widget/StopwatchWidgetModelTest.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index d80b856..491ed21 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -198,6 +198,7 @@ dependencies { implementation("de.jeanlucmakiola.floret:core-di") implementation("de.jeanlucmakiola.floret:identity") implementation("de.jeanlucmakiola.floret:components") + implementation("de.jeanlucmakiola.floret:glance") debugImplementation(libs.androidx.ui.tooling) debugImplementation(libs.androidx.ui.test.manifest) diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index ab01b72..fa94667 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -233,15 +233,59 @@ android:value="true" /> - + + + + + + + + + + + + + + + + + + + + + + + + + @@ -274,6 +318,39 @@ android:name="android.appwidget.provider" android:resource="@xml/widget_clock_info" /> + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/domain/widget/AnalogClockSettings.kt b/app/src/main/java/de/jeanlucmakiola/clockula/domain/widget/AnalogClockSettings.kt new file mode 100644 index 0000000..3e0be17 --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/clockula/domain/widget/AnalogClockSettings.kt @@ -0,0 +1,13 @@ +package de.jeanlucmakiola.clockula.domain.widget + +/** + * The analog faces an [de.jeanlucmakiola.clockula.widget.AnalogClockWidget] + * instance can render: a dial shape (one of the M3 Expressive `MaterialShapes`, + * as `res/drawable/widget_analog_dial_*`) plus what sits on it. See + * `ui/widget/AnalogClockWidgetContent.kt` for the mapping. + */ +enum class AnalogClockStyle { ROUND, NUMERAL, BADGE, COOKIE, SUNNY, SCALLOP, CLOVER, SQUARE, PETAL } + +/** Anything unreadable, or a name this enum no longer has, falls back to [AnalogClockStyle.ROUND]. */ +fun parseAnalogClockStyle(raw: String?): AnalogClockStyle = + raw?.let { runCatching { AnalogClockStyle.valueOf(it) }.getOrNull() } ?: AnalogClockStyle.ROUND diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/domain/widget/ClockWidgetSettings.kt b/app/src/main/java/de/jeanlucmakiola/clockula/domain/widget/ClockWidgetSettings.kt new file mode 100644 index 0000000..b1ab4c0 --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/clockula/domain/widget/ClockWidgetSettings.kt @@ -0,0 +1,29 @@ +package de.jeanlucmakiola.clockula.domain.widget + +/** + * The digital faces a [de.jeanlucmakiola.clockula.widget.ClockWidget] + * instance can render, in picker order. [REGULAR] is the default, so an + * already-placed widget (no stored style) and a "skip configuration" + * placement both render the same plain face. Stored by [name], so the + * original four keep theirs even where their labels changed (Classic, Thin). + */ +enum class DigitalClockStyle { REGULAR, LIGHT, BOLD, PILL, STACKED } + +/** Whether [this] face has room for world-clock rows at all — only the card faces do. */ +val DigitalClockStyle.showsWorldClocks: Boolean + get() = this == DigitalClockStyle.REGULAR || this == DigitalClockStyle.LIGHT || this == DigitalClockStyle.BOLD + +/** Anything unreadable, or a name this enum no longer has, falls back to [DigitalClockStyle.REGULAR]. */ +fun parseDigitalClockStyle(raw: String?): DigitalClockStyle = + raw?.let { runCatching { DigitalClockStyle.valueOf(it) }.getOrNull() } ?: DigitalClockStyle.REGULAR + +/** + * One digital-clock widget instance's own picks, stored per-instance + * (`widget/ClockWidgetState.kt`) — never app-wide. [showDate] defaults to + * true, [showWorldClocks] to false: world clocks are opt-in. + */ +data class DigitalClockSettings( + val style: DigitalClockStyle = DigitalClockStyle.REGULAR, + val showDate: Boolean = true, + val showWorldClocks: Boolean = false, +) diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/domain/widget/QuickTimerWidgetSettings.kt b/app/src/main/java/de/jeanlucmakiola/clockula/domain/widget/QuickTimerWidgetSettings.kt new file mode 100644 index 0000000..b45c8d2 --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/clockula/domain/widget/QuickTimerWidgetSettings.kt @@ -0,0 +1,42 @@ +package de.jeanlucmakiola.clockula.domain.widget + +import de.jeanlucmakiola.clockula.domain.Timer +import de.jeanlucmakiola.clockula.domain.timer.TimerPresets +import kotlin.time.Duration +import kotlin.time.Duration.Companion.milliseconds +import kotlin.time.Duration.Companion.minutes +import kotlin.time.Instant + +/** One quick-timer widget instance's own pick: the duration it starts on tap. */ +data class QuickTimerWidgetSettings(val duration: Duration = DEFAULT) { + companion object { + val DEFAULT: Duration = 5.minutes + } +} + +/** Anything unreadable, or out of [TimerPresets]' own sane range, falls back to [QuickTimerWidgetSettings.DEFAULT]. */ +fun parseQuickTimerDuration(raw: Long?): Duration { + val duration = raw?.milliseconds + return duration?.takeIf { it in TimerPresets.MIN..TimerPresets.MAX } ?: QuickTimerWidgetSettings.DEFAULT +} + +/** + * What the quick-timer widget shows: its configured [duration], and the + * timer it started, while that one is still [active] (running, paused or + * expired). `null` means the widget is at rest, offering to start. + */ +data class QuickTimerWidgetModel(val duration: Duration, val active: TimerWidgetItem?) + +object QuickTimerWidgetPresenter { + + /** + * [timerId] is the timer this widget instance last started. It is + * transient (deleted on reset/stop), so once it is gone — or back to + * idle — the widget is simply at rest again. + */ + fun present(duration: Duration, timerId: Long?, timers: List, elapsedRealtime: Duration, wallClock: Instant): QuickTimerWidgetModel { + val own = timers.filter { it.id == timerId } + val active = TimerWidgetPresenter.present(own, elapsedRealtime, wallClock).items.firstOrNull() + return QuickTimerWidgetModel(duration, active) + } +} diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/domain/widget/StopwatchWidgetModel.kt b/app/src/main/java/de/jeanlucmakiola/clockula/domain/widget/StopwatchWidgetModel.kt new file mode 100644 index 0000000..aa3d404 --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/clockula/domain/widget/StopwatchWidgetModel.kt @@ -0,0 +1,22 @@ +package de.jeanlucmakiola.clockula.domain.widget + +import de.jeanlucmakiola.clockula.domain.StopwatchRun +import de.jeanlucmakiola.clockula.domain.StopwatchState +import de.jeanlucmakiola.clockula.domain.stopwatch.StopwatchReadings +import kotlin.time.Duration + +/** What the stopwatch widget shows: the live [mode] and [elapsed], plus how many laps are banked. */ +data class StopwatchWidgetModel( + val mode: StopwatchState, + val elapsed: Duration, + val lapCount: Int, +) + +object StopwatchWidgetPresenter { + + /** Pure; mirrors `StopwatchReadings.of` — the same "now" every other stopwatch surface reads. */ + fun present(run: StopwatchRun, elapsedRealtime: Duration, lapCount: Int): StopwatchWidgetModel { + val reading = StopwatchReadings.of(run, elapsedRealtime) + return StopwatchWidgetModel(mode = reading.mode, elapsed = reading.elapsed, lapCount = lapCount) + } +} diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/timer/QuickTimerIntents.kt b/app/src/main/java/de/jeanlucmakiola/clockula/timer/QuickTimerIntents.kt new file mode 100644 index 0000000..7c6a6e6 --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/clockula/timer/QuickTimerIntents.kt @@ -0,0 +1,12 @@ +package de.jeanlucmakiola.clockula.timer + +/** The quick-timer widget's start action: a fresh, transient timer at its configured duration. */ +object QuickTimerIntents { + private const val PREFIX = "de.jeanlucmakiola.clockula." + + const val ACTION_START: String = PREFIX + "action.QUICK_TIMER_START" + const val EXTRA_DURATION_MILLIS: String = PREFIX + "extra.QUICK_TIMER_DURATION_MILLIS" + + /** The widget instance that asked, so the timer it started can be shown on it. */ + const val EXTRA_APP_WIDGET_ID: String = PREFIX + "extra.QUICK_TIMER_APP_WIDGET_ID" +} diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/timer/receiver/QuickTimerReceiver.kt b/app/src/main/java/de/jeanlucmakiola/clockula/timer/receiver/QuickTimerReceiver.kt new file mode 100644 index 0000000..73d4b6e --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/clockula/timer/receiver/QuickTimerReceiver.kt @@ -0,0 +1,66 @@ +package de.jeanlucmakiola.clockula.timer.receiver + +import android.appwidget.AppWidgetManager +import android.content.Context +import android.content.Intent +import androidx.glance.appwidget.GlanceAppWidgetManager +import androidx.glance.appwidget.state.updateAppWidgetState +import dagger.hilt.android.AndroidEntryPoint +import de.jeanlucmakiola.clockula.alarm.receiver.HiltBroadcastReceiver +import de.jeanlucmakiola.clockula.data.timers.TimerRepository +import de.jeanlucmakiola.clockula.domain.TimerDraft +import de.jeanlucmakiola.clockula.timer.QuickTimerIntents +import de.jeanlucmakiola.clockula.timer.TimerEngine +import de.jeanlucmakiola.clockula.widget.QuickTimerWidget +import de.jeanlucmakiola.clockula.widget.WIDGET_QUICK_TIMER_ID_KEY +import de.jeanlucmakiola.floret.di.ApplicationScope +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.launch +import javax.inject.Inject +import kotlin.time.Duration.Companion.milliseconds + +/** + * The quick-timer widget's one tap: creates a fresh timer at the widget's + * configured duration and starts it immediately — the same two-step path + * `ui/timers/TimersViewModel.kt`'s quick-start button already uses. A + * broadcast, not an activity, so tapping the widget never has to open the app + * (the same reasoning as `TimerActionReceiver`/`StopwatchActionReceiver`, D16). + */ +@AndroidEntryPoint +class QuickTimerReceiver : HiltBroadcastReceiver() { + + @Inject lateinit var timers: TimerRepository + + @Inject lateinit var engine: TimerEngine + + @Inject + @ApplicationScope + lateinit var scope: CoroutineScope + + override fun onReceive(context: Context, intent: Intent) { + super.onReceive(context, intent) + + if (intent.action != QuickTimerIntents.ACTION_START) return + val durationMillis = intent.getLongExtra(QuickTimerIntents.EXTRA_DURATION_MILLIS, 0L) + if (durationMillis <= 0L) return + val appWidgetId = intent.getIntExtra(QuickTimerIntents.EXTRA_APP_WIDGET_ID, AppWidgetManager.INVALID_APPWIDGET_ID) + val pendingResult = goAsync() + scope.launch { + try { + // Transient: the widget's own reset/stop deletes it rather + // than leaving a stray idle timer in the Timers list. + val id = timers.create(TimerDraft(duration = durationMillis.milliseconds, deleteAfterUse = true)) + engine.start(id) + // Remember which timer this widget started, so it shows that + // one's countdown and controls (`ui/widget/QuickTimerWidgetContent.kt`). + val glanceId = runCatching { GlanceAppWidgetManager(context).getGlanceIdBy(appWidgetId) }.getOrNull() + if (glanceId != null) { + updateAppWidgetState(context, glanceId) { prefs -> prefs[WIDGET_QUICK_TIMER_ID_KEY] = id } + QuickTimerWidget().update(context, glanceId) + } + } finally { + pendingResult.finish() + } + } + } +} diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/ui/widget/AnalogClockWidgetContent.kt b/app/src/main/java/de/jeanlucmakiola/clockula/ui/widget/AnalogClockWidgetContent.kt new file mode 100644 index 0000000..28e174e --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/clockula/ui/widget/AnalogClockWidgetContent.kt @@ -0,0 +1,246 @@ +package de.jeanlucmakiola.clockula.ui.widget + +import android.content.Context +import android.content.Intent +import android.graphics.Bitmap +import android.graphics.Canvas +import android.graphics.Paint +import android.graphics.Typeface +import android.graphics.drawable.Icon +import android.os.Build +import androidx.annotation.DrawableRes +import android.text.format.DateFormat +import android.util.TypedValue +import android.view.View +import android.widget.RemoteViews +import androidx.compose.runtime.Composable +import androidx.datastore.preferences.core.Preferences +import androidx.glance.GlanceModifier +import androidx.glance.LocalContext +import androidx.glance.LocalSize +import androidx.glance.action.clickable +import androidx.glance.appwidget.AndroidRemoteViews +import androidx.glance.appwidget.action.actionStartActivity +import androidx.glance.currentState +import androidx.glance.layout.Alignment +import androidx.glance.layout.Box +import androidx.glance.layout.fillMaxSize +import androidx.glance.layout.size +import de.jeanlucmakiola.clockula.MainActivity +import de.jeanlucmakiola.clockula.R +import de.jeanlucmakiola.clockula.domain.widget.AnalogClockStyle +import de.jeanlucmakiola.clockula.domain.widget.parseAnalogClockStyle +import de.jeanlucmakiola.clockula.ui.shell.ShellNavigation +import de.jeanlucmakiola.clockula.widget.WIDGET_ANALOG_STYLE_KEY + +/** Reads this instance's own [AnalogClockStyle] reactively, then renders [AnalogClockWidgetContent]. */ +@Composable +fun AnalogClockWidgetRoot() { + val prefs = currentState() + AnalogClockWidgetContent(style = parseAnalogClockStyle(prefs[WIDGET_ANALOG_STYLE_KEY])) +} + +/** + * The analog widget's content: [style]'s dial/overlay ([analogClockViews]) + * floating directly on the home screen, not on a card, the way a platform + * clock widget looks. Its colours are the analog palette resources + * (`res/values/colors.xml` and its night/v31 variants), so it follows the launcher's day/night and, on + * API 31+, the wallpaper by itself. + */ +@Composable +fun AnalogClockWidgetContent(style: AnalogClockStyle) { + val context = LocalContext.current + Box( + modifier = GlanceModifier.fillMaxSize().clickable(actionStartActivity(showWorldClockIntent(context))), + contentAlignment = Alignment.Center, + ) { + AnalogFace(style) + } +} + +/** + * The `AnalogClock` layouts fill whatever they're given (`match_parent`), so + * *this* is what actually sizes the dial: the smaller of the widget's live + * placed width/height, read from [LocalSize]. + */ +@Composable +private fun AnalogFace(style: AnalogClockStyle) { + val context = LocalContext.current + val size = LocalSize.current + val side = minOf(size.width, size.height) + Box(modifier = GlanceModifier.size(side)) { + AndroidRemoteViews( + remoteViews = analogClockViews(context, style, side.value), + modifier = GlanceModifier.fillMaxSize(), + ) + } +} + +/** + * What sits on an analog dial besides the hands — picks the layout. [DAY] is + * the 12/9/6 numerals plus a day pill at three; [DATE_PILL] is that layout + * with the numerals hidden and the full date ("Fri 2") in the pill. + */ +private enum class AnalogOverlay { NONE, NUMERALS, DAY, DATE_PILL } + +private fun AnalogClockStyle.dial(): Int = when (this) { + AnalogClockStyle.ROUND, AnalogClockStyle.NUMERAL, AnalogClockStyle.BADGE -> R.drawable.widget_analog_dial_round + AnalogClockStyle.COOKIE -> R.drawable.widget_analog_dial_cookie + AnalogClockStyle.CLOVER -> R.drawable.widget_analog_dial_clover + AnalogClockStyle.PETAL -> R.drawable.widget_analog_dial_petal + AnalogClockStyle.SUNNY -> R.drawable.widget_analog_dial_sunny + AnalogClockStyle.SQUARE -> R.drawable.widget_analog_dial_square + AnalogClockStyle.SCALLOP -> R.drawable.widget_analog_dial_scallop +} + +/** The hour/minute hand drawables a style wears (`res/drawable/widget_analog_{hour,minute}_*`). */ +private enum class AnalogHands(@DrawableRes val hour: Int, @DrawableRes val minute: Int) { + CAPSULE(R.drawable.widget_analog_hour_hand, R.drawable.widget_analog_minute_hand), + BOLD(R.drawable.widget_analog_hour_bold, R.drawable.widget_analog_minute_bold), + SLIM(R.drawable.widget_analog_hour_slim, R.drawable.widget_analog_minute_slim), + LOLLIPOP(R.drawable.widget_analog_hour_lollipop, R.drawable.widget_analog_minute_lollipop), +} + +private fun AnalogClockStyle.hands(): AnalogHands = when (this) { + AnalogClockStyle.ROUND, AnalogClockStyle.BADGE, AnalogClockStyle.SCALLOP -> AnalogHands.CAPSULE + AnalogClockStyle.COOKIE, AnalogClockStyle.SQUARE -> AnalogHands.BOLD + AnalogClockStyle.NUMERAL, AnalogClockStyle.CLOVER -> AnalogHands.SLIM + AnalogClockStyle.SUNNY, AnalogClockStyle.PETAL -> AnalogHands.LOLLIPOP +} + +/** What a style's seconds hand is, if it has one at all. */ +private sealed interface AnalogSeconds { + data object None : AnalogSeconds + + /** A dot alone; the drawable fixes its size and orbit. */ + data class Dot(@DrawableRes val drawable: Int) : AnalogSeconds + + /** Google Clock's: a dot plus the date opposite it. */ + data class DotAndDate(@DrawableRes val dot: Int) : AnalogSeconds +} + +private fun AnalogClockStyle.seconds(): AnalogSeconds = when (this) { + AnalogClockStyle.ROUND, AnalogClockStyle.NUMERAL, AnalogClockStyle.BADGE, + AnalogClockStyle.CLOVER -> AnalogSeconds.None + AnalogClockStyle.SUNNY -> AnalogSeconds.Dot(R.drawable.widget_analog_second_dot) + AnalogClockStyle.SQUARE -> AnalogSeconds.Dot(R.drawable.widget_analog_second_dot_bold) + AnalogClockStyle.PETAL -> AnalogSeconds.Dot(R.drawable.widget_analog_second_dot_petal) + AnalogClockStyle.COOKIE -> AnalogSeconds.DotAndDate(R.drawable.widget_analog_second_dot_dated) + AnalogClockStyle.SCALLOP -> AnalogSeconds.DotAndDate(R.drawable.widget_analog_second_dot_scallop) +} + +private fun AnalogClockStyle.overlay(): AnalogOverlay = when (this) { + AnalogClockStyle.NUMERAL, AnalogClockStyle.CLOVER -> AnalogOverlay.NUMERALS + AnalogClockStyle.BADGE -> AnalogOverlay.DAY + AnalogClockStyle.PETAL -> AnalogOverlay.DATE_PILL + AnalogClockStyle.ROUND, AnalogClockStyle.COOKIE, AnalogClockStyle.SUNNY, + AnalogClockStyle.SCALLOP, AnalogClockStyle.SQUARE -> AnalogOverlay.NONE +} + +/** + * The analog face for [style] at a [sideDp]-square size. The single builder + * behind both the placed widget and the config picker's previews (which + * inflate these same views in-app), so a preview is the real widget. + * Numerals, badge and their insets are fractions of [sideDp], so they grow + * and shrink with the dial instead of staying at a fixed sp/dp. + */ +internal fun analogClockViews(context: Context, style: AnalogClockStyle, sideDp: Float): RemoteViews { + val overlay = style.overlay() + val layoutRes = when (overlay) { + AnalogOverlay.NONE -> R.layout.widget_analog_plain + AnalogOverlay.NUMERALS -> R.layout.widget_analog_numeral + AnalogOverlay.DAY, AnalogOverlay.DATE_PILL -> R.layout.widget_analog_badge + } + val density = context.resources.displayMetrics.density + fun frac(f: Float): Int = (sideDp * f * density).toInt() + return RemoteViews(context.packageName, layoutRes).apply { + setImageViewResource(R.id.widget_analog_dial, style.dial()) + // `AnalogClock`'s hand setters (and its seconds hand at all) only + // exist from API 31; before that every style keeps the layout's + // capsule hands and no seconds. + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + val hands = style.hands() + setIcon(R.id.widget_clock_analog, "setHourHand", Icon.createWithResource(context, hands.hour)) + setIcon(R.id.widget_clock_analog, "setMinuteHand", Icon.createWithResource(context, hands.minute)) + when (val seconds = style.seconds()) { + AnalogSeconds.None -> Unit + is AnalogSeconds.Dot -> + setIcon(R.id.widget_clock_analog, "setSecondHand", Icon.createWithResource(context, seconds.drawable)) + is AnalogSeconds.DotAndDate -> { + setIcon(R.id.widget_clock_analog, "setSecondHand", Icon.createWithResource(context, seconds.dot)) + // Only the plain layout has the date clock, and only + // plain faces are dated. + setViewVisibility(R.id.widget_clock_analog_date, View.VISIBLE) + setIcon(R.id.widget_clock_analog_date, "setSecondHand", Icon.createWithBitmap(dateHand(context))) + setColorStateList(R.id.widget_clock_analog_date, "setSecondHandTintList", R.color.widget_analog_date) + } + } + } + if (overlay == AnalogOverlay.NONE) return@apply + val inset = frac(0.07f) + val numerals = if (overlay == AnalogOverlay.DATE_PILL) emptyList() else buildList { + add(R.id.widget_analog_numeral_12 to intArrayOf(0, inset, 0, 0)) + add(R.id.widget_analog_numeral_6 to intArrayOf(0, 0, 0, inset)) + add(R.id.widget_analog_numeral_9 to intArrayOf(inset, 0, 0, 0)) + if (overlay == AnalogOverlay.NUMERALS) add(R.id.widget_analog_numeral_3 to intArrayOf(0, 0, inset, 0)) + } + numerals.forEach { (id, p) -> + setTextViewTextSize(id, TypedValue.COMPLEX_UNIT_DIP, sideDp * 0.15f) + setViewPadding(id, p[0], p[1], p[2], p[3]) + } + if (overlay == AnalogOverlay.DATE_PILL) { + listOf(R.id.widget_analog_numeral_12, R.id.widget_analog_numeral_6, R.id.widget_analog_numeral_9) + .forEach { setViewVisibility(it, View.GONE) } + } + if (overlay == AnalogOverlay.DAY || overlay == AnalogOverlay.DATE_PILL) { + val skeleton = if (overlay == AnalogOverlay.DAY) "EEE" else "EEEd" + val pattern = DateFormat.getBestDateTimePattern(currentLocale(context), skeleton) + setCharSequence(R.id.widget_analog_badge_day, "setFormat12Hour", pattern) + setCharSequence(R.id.widget_analog_badge_day, "setFormat24Hour", pattern) + setTextViewTextSize(R.id.widget_analog_badge_day, TypedValue.COMPLEX_UNIT_DIP, sideDp * 0.075f) + setViewPadding(R.id.widget_analog_badge_day, frac(0.045f), frac(0.02f), frac(0.045f), frac(0.02f)) + // Petal's rim dips in between its lobes, so its pill sits further in. + val edgeInset = if (overlay == AnalogOverlay.DAY) 0.06f else 0.1f + setViewPadding(R.id.widget_analog_badge_frame, 0, 0, frac(edgeInset), 0) + } + } +} + +/** + * The date half of Google Clock's seconds hand: today's date ("Fri 2") + * opposite the seconds dot, on the layout's separate date clock. Text cannot + * live in a vector drawable, so this hand is a bitmap — an alpha mask, tinted by + * `setSecondHandTintList` in the launcher so it still follows day/night. + * The date goes stale at midnight until the next update; the provider's 30 + * minute `updatePeriodMillis` bounds that. + */ +private fun dateHand(context: Context): Bitmap { + val px = 360 + val unit = px / 100f + val bitmap = Bitmap.createBitmap(px, px, Bitmap.Config.ALPHA_8) + // 360px at 120dpi reads as 480dp — the intrinsic size every other hand + // and the dial share, so `AnalogClock` scales them all alike. Kept to + // 360px (and alpha-only) so the bitmap stays small in the RemoteViews. + bitmap.density = 120 + val canvas = Canvas(bitmap) + val paint = Paint(Paint.ANTI_ALIAS_FLAG) + paint.textSize = 9.5f * unit + paint.typeface = Typeface.create(Typeface.DEFAULT, 600, false) + paint.textAlign = Paint.Align.CENTER + val pattern = DateFormat.getBestDateTimePattern(currentLocale(context), "EEEd") + val date = DateFormat.format(pattern, System.currentTimeMillis()).toString() + val metrics = paint.fontMetrics + // Centred at radius 33, so the text stays inside the dated faces' + // notches (41+) even at its corners. + val dateY = 83 * unit + // Drawn upside down here, below the pivot, so it reads upright when the + // hand carries it over the top of the dial — where it is read most. + canvas.rotate(180f, 50 * unit, dateY) + canvas.drawText(date, 50 * unit, dateY - (metrics.ascent + metrics.descent) / 2, paint) + return bitmap +} + +private fun showWorldClockIntent(context: Context): Intent = + Intent(context, MainActivity::class.java) + .setAction(ShellNavigation.ACTION_SHOW_WORLD_CLOCK) + .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/ui/widget/ClockWidgetContent.kt b/app/src/main/java/de/jeanlucmakiola/clockula/ui/widget/ClockWidgetContent.kt index 9d3e764..41f0d70 100644 --- a/app/src/main/java/de/jeanlucmakiola/clockula/ui/widget/ClockWidgetContent.kt +++ b/app/src/main/java/de/jeanlucmakiola/clockula/ui/widget/ClockWidgetContent.kt @@ -2,88 +2,86 @@ package de.jeanlucmakiola.clockula.ui.widget import android.content.Context import android.content.Intent -import android.widget.RemoteViews import androidx.compose.runtime.Composable -import androidx.compose.ui.unit.Dp +import androidx.compose.runtime.getValue +import androidx.compose.runtime.produceState +import androidx.datastore.preferences.core.Preferences import androidx.glance.GlanceModifier -import androidx.glance.GlanceTheme import androidx.glance.LocalContext import androidx.glance.LocalSize -import androidx.glance.text.Text import androidx.glance.action.clickable import androidx.glance.appwidget.AndroidRemoteViews import androidx.glance.appwidget.action.actionStartActivity -import androidx.glance.appwidget.cornerRadius -import androidx.glance.background -import androidx.glance.layout.Alignment +import androidx.glance.currentState import androidx.glance.layout.Box -import androidx.glance.layout.Column import androidx.glance.layout.fillMaxSize -import androidx.glance.layout.padding -import androidx.glance.text.TextStyle import de.jeanlucmakiola.clockula.MainActivity -import de.jeanlucmakiola.clockula.R +import de.jeanlucmakiola.clockula.data.worldclocks.WorldClockRepository +import de.jeanlucmakiola.clockula.data.zones.ZoneNames +import de.jeanlucmakiola.clockula.domain.widget.ClockWidgetPresenter +import de.jeanlucmakiola.clockula.domain.widget.DigitalClockSettings import de.jeanlucmakiola.clockula.ui.shell.ShellNavigation +import de.jeanlucmakiola.clockula.widget.WIDGET_DIGITAL_STYLE_KEY +import de.jeanlucmakiola.clockula.widget.WIDGET_SHOW_DATE_KEY +import de.jeanlucmakiola.clockula.widget.WIDGET_SHOW_WORLD_CLOCKS_KEY +import de.jeanlucmakiola.clockula.widget.di.WidgetEntryPoint +import de.jeanlucmakiola.clockula.widget.parseDigitalClockSettings +import kotlinx.coroutines.flow.first -/** One resolved world clock for the clock widget: the zone id a `TextClock` ticks against, with its already-resolved city name. */ +/** One resolved world clock for the digital widget: the zone id a `TextClock` ticks against, with its already-resolved city name. */ data class ClockWidgetCity(val zoneId: String, val cityName: String) +/** The world clocks the digital widget shows, resolved to city names — shared by the widget and its config preview. */ +internal suspend fun widgetCities(worldClocks: WorldClockRepository, zoneNames: ZoneNames): List = + ClockWidgetPresenter.present(worldClocks.worldClocks().first()).map { clock -> + ClockWidgetCity( + zoneId = clock.zoneId, + cityName = clock.label?.takeIf(String::isNotBlank) ?: zoneNames.cityOf(clock.zoneId) ?: clock.zoneId, + ) + } + /** - * The clock widget's content. Responsive by size (`LocalSize.current`), read - * here rather than via `SizeMode.Responsive` so the `GlanceAppWidget` subclass - * in `widget/` never needs a Compose-namespace `DpSize` import - * (`ArchitectureRulesTest`'s "Compose stays inside ui/" rule). + * Reads this widget instance's own state — reactively, so a config-screen + * save that lands on a still-live Glance session (see `widget/ClockWidget.kt`) + * recomposes with the new values instead of the ones the session started + * with — then renders [ClockWidgetContent]. */ @Composable -fun ClockWidgetContent(cities: List) { +fun ClockWidgetRoot(entryPoint: WidgetEntryPoint) { + val prefs = currentState() + val settings = parseDigitalClockSettings( + style = prefs[WIDGET_DIGITAL_STYLE_KEY], + showDate = prefs[WIDGET_SHOW_DATE_KEY], + showWorldClocks = prefs[WIDGET_SHOW_WORLD_CLOCKS_KEY], + ) + val showWorldClocks = settings.showWorldClocks + val cities by produceState(initialValue = emptyList(), showWorldClocks) { + value = if (showWorldClocks) widgetCities(entryPoint.worldClockRepository(), entryPoint.zoneNames()) else emptyList() + } + + ClockWidgetContent(cities = cities, settings = settings) +} + +/** + * The digital widget's content: [settings]' face ([digitalClockViews]) at + * the widget's live size. Like the analog widget, the face draws its own + * container (a card, pills, or a shape) and takes its colours from the + * `widget_*` palette resources, so it follows the launcher's day/night and + * the wallpaper by itself. + */ +@Composable +fun ClockWidgetContent(cities: List, settings: DigitalClockSettings) { val context = LocalContext.current val size = LocalSize.current - val showDate = size.width >= MEDIUM_WIDTH - val showCities = size.width >= LARGE_WIDTH && cities.isNotEmpty() - - GlanceTheme(WidgetDefaults.Colors) { - Box( - modifier = GlanceModifier - .fillMaxSize() - .background(GlanceTheme.colors.widgetBackground) - .cornerRadius(WidgetDefaults.CornerRadius) - .padding(WidgetDefaults.ContentPadding) - .clickable(actionStartActivity(showWorldClockIntent(context))), - contentAlignment = Alignment.CenterStart, - ) { - Column { - TextClockRow(context, zoneId = null) - if (showDate) { - Text(todayLabel(), style = TextStyle(color = GlanceTheme.colors.onSurfaceVariant, fontSize = WidgetDefaults.BodyTextSize)) - } - if (showCities) { - cities.forEach { city -> - Text(city.cityName, style = TextStyle(color = GlanceTheme.colors.onSurfaceVariant, fontSize = WidgetDefaults.CaptionTextSize)) - TextClockRow(context, zoneId = city.zoneId) - } - } - } - } + Box(modifier = GlanceModifier.fillMaxSize().clickable(actionStartActivity(showWorldClockIntent(context)))) { + AndroidRemoteViews( + remoteViews = digitalClockViews(context, settings, cities, size.width.value, size.height.value), + modifier = GlanceModifier.fillMaxSize(), + ) } } -/** [zoneId] null means the device's own zone — a plain `TextClock` with no timezone override. */ -@Composable -private fun TextClockRow(context: Context, zoneId: String?) { - val views = RemoteViews(context.packageName, R.layout.widget_text_clock).apply { - if (zoneId != null) setString(R.id.widget_text_clock, "setTimeZone", zoneId) - } - AndroidRemoteViews(remoteViews = views) -} - -/** A plain `java.time` date string — no `android.icu`, this is not a localised zone name (`data/zones/` owns that). */ -private fun todayLabel(): String = - java.time.LocalDate.now().format(java.time.format.DateTimeFormatter.ofPattern("EEE, MMM d")) - private fun showWorldClockIntent(context: Context): Intent = Intent(context, MainActivity::class.java) .setAction(ShellNavigation.ACTION_SHOW_WORLD_CLOCK) .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) - -private val MEDIUM_WIDTH = Dp(180f) -private val LARGE_WIDTH = Dp(250f) diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/ui/widget/DigitalFaces.kt b/app/src/main/java/de/jeanlucmakiola/clockula/ui/widget/DigitalFaces.kt new file mode 100644 index 0000000..666fa82 --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/clockula/ui/widget/DigitalFaces.kt @@ -0,0 +1,158 @@ +package de.jeanlucmakiola.clockula.ui.widget + +import android.content.Context +import android.text.format.DateFormat +import android.util.TypedValue +import android.view.View +import android.widget.RemoteViews +import de.jeanlucmakiola.clockula.R +import de.jeanlucmakiola.clockula.domain.widget.DigitalClockSettings +import de.jeanlucmakiola.clockula.domain.widget.DigitalClockStyle +import de.jeanlucmakiola.clockula.domain.widget.showsWorldClocks + +/** Below this the time is too small to share the card with world-clock rows, so they drop first. */ +private const val MIN_TIME_WITH_CITIES = 30f + +/** Below this the time drops the date line too. */ +private const val MIN_TIME_WITH_DATE = 22f + +/** + * The digital face for [settings] at [widthDp] x [heightDp]. The single + * builder behind both the placed widget and the config picker's previews + * (which inflate these same views in-app), so a preview is the real widget — + * as `analogClockViews` is for the analog one. Every text size and spacing + * is a fraction of the size, so the face fills the widget at any size + * instead of sitting at a fixed sp in the middle of it; on a small widget, + * world clocks and then the date drop out rather than squeezing the time. + * + * The time and date are `TextClock`s ticking in the launcher; the time drops + * the 12-hour AM/PM marker, as the platform's own clock widgets do. + */ +internal fun digitalClockViews( + context: Context, + settings: DigitalClockSettings, + cities: List, + widthDp: Float, + heightDp: Float, +): RemoteViews = when (settings.style) { + DigitalClockStyle.REGULAR, DigitalClockStyle.LIGHT, DigitalClockStyle.BOLD -> + cardViews(context, settings, cities, widthDp, heightDp) + DigitalClockStyle.PILL -> pillViews(context, settings.showDate, widthDp, heightDp) + DigitalClockStyle.STACKED -> stackedViews(context, settings.showDate, minOf(widthDp, heightDp)) +} + +/** The card faces: time, date, then up to three world-clock rows. */ +private fun cardViews( + context: Context, + settings: DigitalClockSettings, + cities: List, + w: Float, + h: Float, +): RemoteViews { + val (layout, cityLayout, emsWide) = when (settings.style) { + DigitalClockStyle.LIGHT -> Triple(R.layout.widget_digital_thin, R.layout.widget_digital_city, 2.6f) + DigitalClockStyle.BOLD -> Triple(R.layout.widget_digital_bold, R.layout.widget_digital_city_on_primary, 2.95f) + else -> Triple(R.layout.widget_digital_classic, R.layout.widget_digital_city, 2.75f) + } + val pad = (minOf(w, h) * 0.1f).coerceIn(10f, 22f) + val innerW = w - 2 * pad + val innerH = h - 2 * pad + + // Heights in ems of the time: its own line, the date's (0.3em text), and + // each city row's (0.3em text) after a small gap. + fun timeSize(cityCount: Int, date: Boolean): Float { + val ems = 1.12f + (if (date) 0.42f else 0f) + (if (cityCount > 0) 0.12f + cityCount * 0.44f else 0f) + return minOf(innerW / emsWide, innerH / ems) + } + var cityCount = if (settings.showWorldClocks && settings.style.showsWorldClocks) cities.size else 0 + var showDate = settings.showDate + while (cityCount > 0 && timeSize(cityCount, showDate) < MIN_TIME_WITH_CITIES) cityCount-- + if (showDate && timeSize(cityCount, true) < MIN_TIME_WITH_DATE) showDate = false + val time = timeSize(cityCount, showDate) + val small = (time * 0.3f).coerceAtLeast(10f) + val px = pxOf(context) + + return RemoteViews(context.packageName, layout).apply { + setViewPadding(R.id.widget_digital_root, px(pad), px(pad), px(pad), px(pad)) + setTime(context, R.id.widget_digital_time, time) + setDate(context, R.id.widget_digital_date, showDate, small, "EEEMMMd") + if (cityCount > 0) { + setViewVisibility(R.id.widget_digital_cities, View.VISIBLE) + setViewPadding(R.id.widget_digital_cities, 0, px(time * 0.12f), 0, 0) + val cityPattern = if (DateFormat.is24HourFormat(context)) "HH:mm" else "h:mm a" + cities.take(cityCount).forEachIndexed { index, city -> + val row = RemoteViews(context.packageName, cityLayout).apply { + setTextViewText(R.id.widget_digital_city_name, city.cityName) + setTextViewTextSize(R.id.widget_digital_city_name, TypedValue.COMPLEX_UNIT_DIP, small) + setString(R.id.widget_digital_city_time, "setTimeZone", city.zoneId) + setCharSequence(R.id.widget_digital_city_time, "setFormat12Hour", cityPattern) + setCharSequence(R.id.widget_digital_city_time, "setFormat24Hour", cityPattern) + setTextViewTextSize(R.id.widget_digital_city_time, TypedValue.COMPLEX_UNIT_DIP, small) + // Each row after the first sits a little below the one before. + if (index > 0) setViewPadding(R.id.widget_digital_city_name, 0, px(small * 0.4f), 0, 0) + if (index > 0) setViewPadding(R.id.widget_digital_city_time, 0, px(small * 0.4f), 0, 0) + } + addView(R.id.widget_digital_cities, row) + } + } + } +} + +/** The time in a primary pill, the date in a smaller tertiary pill under it. */ +private fun pillViews(context: Context, showDate: Boolean, w: Float, h: Float): RemoteViews { + // The pill is 2.85em of digits plus 0.5em padding either side, 1.4em tall; + // the date adds a 0.14em gap and a 0.34em-text pill about 0.6em tall. + val time = minOf(w * 0.94f / 3.85f, h * 0.94f / (if (showDate) 2.15f else 1.4f)) + val date = time * 0.34f + val px = pxOf(context) + return RemoteViews(context.packageName, R.layout.widget_digital_pill).apply { + setTime(context, R.id.widget_digital_time, time) + setViewPadding(R.id.widget_digital_time, px(time * 0.5f), px(time * 0.14f), px(time * 0.5f), px(time * 0.14f)) + setDate(context, R.id.widget_digital_date, showDate, date, "EEEMMMd") + if (showDate) { + setViewPadding(R.id.widget_digital_date_frame, 0, px(time * 0.14f), 0, 0) + setViewPadding(R.id.widget_digital_date, px(date * 0.8f), px(date * 0.3f), px(date * 0.8f), px(date * 0.3f)) + } else { + setViewVisibility(R.id.widget_digital_date_frame, View.GONE) + } + } +} + +/** Hours over minutes on a Square of [side], sized so both fit inside its rounded corners. */ +private fun stackedViews(context: Context, showDate: Boolean, side: Float): RemoteViews { + val digits = side * (if (showDate) 0.3f else 0.34f) + val is24 = DateFormat.is24HourFormat(context) + return RemoteViews(context.packageName, R.layout.widget_digital_stacked).apply { + setClock(R.id.widget_digital_hours, if (is24) "HH" else "hh", digits) + setClock(R.id.widget_digital_minutes, "mm", digits) + setDate(context, R.id.widget_digital_date, showDate, side * 0.075f, "EEEd") + } +} + +/** The main time: `h:mm` / `HH:mm` — the 12-hour form without AM/PM. */ +private fun RemoteViews.setTime(context: Context, viewId: Int, sizeDp: Float) = + setClock(viewId, if (DateFormat.is24HourFormat(context)) "HH:mm" else "h:mm", sizeDp) + +/** One `TextClock` showing [pattern] whatever the 12/24-hour setting — the caller has already picked by it. */ +private fun RemoteViews.setClock(viewId: Int, pattern: String, sizeDp: Float) { + setCharSequence(viewId, "setFormat12Hour", pattern) + setCharSequence(viewId, "setFormat24Hour", pattern) + setTextViewTextSize(viewId, TypedValue.COMPLEX_UNIT_DIP, sizeDp) +} + +/** + * A date-only `TextClock` — no time tokens — so it reads as a date line but + * still re-renders itself at midnight with no app-side ticking. + */ +private fun RemoteViews.setDate(context: Context, viewId: Int, show: Boolean, sizeDp: Float, skeleton: String) { + if (!show) { + setViewVisibility(viewId, View.GONE) + return + } + setClock(viewId, DateFormat.getBestDateTimePattern(currentLocale(context), skeleton), sizeDp) +} + +private fun pxOf(context: Context): (Float) -> Int { + val density = context.resources.displayMetrics.density + return { dp -> (dp * density).toInt() } +} diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/ui/widget/NextAlarmWidgetContent.kt b/app/src/main/java/de/jeanlucmakiola/clockula/ui/widget/NextAlarmWidgetContent.kt index dde3dc7..bca4546 100644 --- a/app/src/main/java/de/jeanlucmakiola/clockula/ui/widget/NextAlarmWidgetContent.kt +++ b/app/src/main/java/de/jeanlucmakiola/clockula/ui/widget/NextAlarmWidgetContent.kt @@ -4,22 +4,21 @@ import android.content.Context import android.content.Intent import android.text.format.DateFormat import androidx.compose.runtime.Composable +import androidx.compose.ui.unit.dp import androidx.glance.GlanceModifier -import androidx.glance.LocalContext -import androidx.glance.text.Text import androidx.glance.GlanceTheme -import androidx.glance.action.clickable +import androidx.glance.LocalContext +import androidx.glance.LocalSize import androidx.glance.appwidget.action.actionStartActivity -import androidx.glance.appwidget.cornerRadius -import androidx.glance.background import androidx.glance.layout.Alignment -import androidx.glance.layout.Box import androidx.glance.layout.Column -import androidx.glance.layout.fillMaxSize +import androidx.glance.layout.Row +import androidx.glance.layout.Spacer import androidx.glance.layout.padding +import androidx.glance.layout.width import androidx.glance.text.FontWeight +import androidx.glance.text.Text import androidx.glance.text.TextStyle -import androidx.glance.unit.ColorProvider import de.jeanlucmakiola.clockula.MainActivity import de.jeanlucmakiola.clockula.R import de.jeanlucmakiola.clockula.alarm.AlarmIntents @@ -29,32 +28,97 @@ import java.time.format.DateTimeFormatter import java.time.format.FormatStyle import java.time.format.TextStyle as JavaTextStyle import java.util.Locale +import de.jeanlucmakiola.clockula.widget.di.WidgetEntryPoint +import de.jeanlucmakiola.clockula.domain.widget.NextAlarmWidgetPresenter +import de.jeanlucmakiola.clockula.domain.alarm.UpcomingAlarm +import androidx.compose.runtime.remember +import androidx.compose.runtime.getValue +import androidx.compose.runtime.collectAsState -/** The next-alarm widget's content, given an already-resolved [model]. */ +/** + * Reads the upcoming alarms reactively, then renders [NextAlarmWidgetContent]. + * Collected *inside* the composition: `update()` on a still-live session (one + * lives ~45s) only recomposes, so a value read once before `provideContent` + * would keep showing what was true when the session started — the + * `initial` value only makes the first frame right. + */ +@Composable +fun NextAlarmWidgetRoot(entryPoint: WidgetEntryPoint, initial: List) { + val upcoming by remember { entryPoint.alarmEngine().upcoming() }.collectAsState(initial = initial) + val model = remember(upcoming) { + NextAlarmWidgetPresenter.present(upcoming = upcoming, now = entryPoint.wallClock().now(), zone = entryPoint.zoneProvider().current()) + } + NextAlarmWidgetContent(model) +} + +/** + * The next-alarm widget's content, given an already-resolved [model]: a + * Cookie badge carrying the alarm glyph, then the day, the time — the + * largest thing on it — and the label, all sized from the widget's live size. + */ @Composable fun NextAlarmWidgetContent(model: NextAlarmWidgetModel) { val context = LocalContext.current - GlanceTheme(WidgetDefaults.Colors) { - Box( - modifier = GlanceModifier - .fillMaxSize() - .background(GlanceTheme.colors.widgetBackground) - .cornerRadius(WidgetDefaults.CornerRadius) - .padding(WidgetDefaults.ContentPadding) - .clickable(actionStartActivity(showAlarmsIntent(context))), - contentAlignment = Alignment.CenterStart, - ) { + val size = LocalSize.current + val w = size.width.value + val h = size.height.value + val pad = (minOf(w, h) * 0.14f).coerceIn(10f, 22f) + val innerH = h - 2 * pad + val innerW = w - 2 * pad + // The badge takes the card's height, but never more than a third of + // its width, so a narrow widget keeps room for the time. + val badge = minOf(innerH, innerW * 0.34f).coerceAtMost(96f) + val gap = badge * 0.22f + val textW = innerW - badge - gap + + WidgetCard(onClick = actionStartActivity(showAlarmsIntent(context)), contentAlignment = Alignment.CenterStart) { + Row(modifier = GlanceModifier.padding(pad.dp), verticalAlignment = Alignment.CenterVertically) { + ShapedIcon( + icon = if (model.hasAlarm) R.drawable.widget_ic_alarm else R.drawable.widget_ic_alarm_off, + contentDescription = null, + width = badge.dp, + height = badge.dp, + container = if (model.hasAlarm) GlanceTheme.colors.primaryContainer else GlanceTheme.colors.secondaryContainer, + content = if (model.hasAlarm) GlanceTheme.colors.onPrimaryContainer else GlanceTheme.colors.onSecondaryContainer, + shape = R.drawable.widget_shape_cookie, + iconFraction = 0.48f, + ) + Spacer(GlanceModifier.width(gap.dp)) if (!model.hasAlarm) { Text( text = context.getString(R.string.widget_next_alarm_empty), - style = bodyStyle(GlanceTheme.colors.onSurfaceVariant), + style = TextStyle( + color = GlanceTheme.colors.onSurfaceVariant, + fontSize = fittedSp(context, minOf(innerH * 0.26f, textW / 7f).coerceIn(11f, 20f)), + fontWeight = FontWeight.Medium, + ), + maxLines = 2, ) } else { + val time = timeLabel(context, model) + val hasLabel = model.label.isNotBlank() + // Caption + time (+ label) stacked in the badge's height; + // the time's width is about 0.62em per character. + val lines = if (hasLabel) 2.1f else 1.65f + val timeSize = minOf(innerH / lines, textW / (time.length * 0.62f)).coerceAtLeast(14f) + val caption = (timeSize * 0.36f).coerceIn(11f, 18f) Column { - Text(dayLabel(context, model), style = captionStyle(GlanceTheme.colors.onSurfaceVariant)) - Text(timeLabel(context, model), style = timeStyle(GlanceTheme.colors.onSurface)) - if (model.label.isNotBlank()) { - Text(model.label, style = bodyStyle(GlanceTheme.colors.onSurfaceVariant)) + Text( + text = dayLabel(context, model), + style = TextStyle(color = GlanceTheme.colors.primary, fontSize = fittedSp(context, caption), fontWeight = FontWeight.Medium), + maxLines = 1, + ) + Text( + text = time, + style = TextStyle(color = GlanceTheme.colors.onSurface, fontSize = fittedSp(context, timeSize), fontWeight = FontWeight.Medium), + maxLines = 1, + ) + if (hasLabel) { + Text( + text = model.label, + style = TextStyle(color = GlanceTheme.colors.onSurfaceVariant, fontSize = fittedSp(context, caption)), + maxLines = 1, + ) } } } @@ -82,10 +146,3 @@ private fun dayLabel(context: Context, model: NextAlarmWidgetModel): String = wh model.dayOfWeek != null -> model.dayOfWeek.getDisplayName(JavaTextStyle.FULL, Locale.getDefault()) else -> "" } - -private fun timeStyle(color: ColorProvider) = - TextStyle(color = color, fontSize = WidgetDefaults.PrimaryTextSize, fontWeight = FontWeight.Medium) - -private fun bodyStyle(color: ColorProvider) = TextStyle(color = color, fontSize = WidgetDefaults.BodyTextSize) - -private fun captionStyle(color: ColorProvider) = TextStyle(color = color, fontSize = WidgetDefaults.CaptionTextSize) diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/ui/widget/QuickTimerWidgetContent.kt b/app/src/main/java/de/jeanlucmakiola/clockula/ui/widget/QuickTimerWidgetContent.kt new file mode 100644 index 0000000..e59130f --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/clockula/ui/widget/QuickTimerWidgetContent.kt @@ -0,0 +1,174 @@ +package de.jeanlucmakiola.clockula.ui.widget + +import android.content.Context +import android.content.Intent +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.unit.dp +import androidx.datastore.preferences.core.Preferences +import androidx.glance.ColorFilter +import androidx.glance.GlanceModifier +import androidx.glance.GlanceTheme +import androidx.glance.Image +import androidx.glance.ImageProvider +import androidx.glance.LocalContext +import androidx.glance.LocalGlanceId +import androidx.glance.LocalSize +import androidx.glance.appwidget.GlanceAppWidgetManager +import androidx.glance.appwidget.action.actionSendBroadcast +import androidx.glance.background +import androidx.glance.currentState +import androidx.glance.layout.Alignment +import androidx.glance.layout.Column +import androidx.glance.layout.fillMaxSize +import androidx.glance.layout.size +import androidx.glance.semantics.contentDescription +import androidx.glance.semantics.semantics +import de.jeanlucmakiola.clockula.R +import de.jeanlucmakiola.clockula.domain.Timer +import de.jeanlucmakiola.clockula.domain.format.ClockFormat +import de.jeanlucmakiola.clockula.domain.timer.TimerMode +import de.jeanlucmakiola.clockula.domain.widget.QuickTimerWidgetModel +import de.jeanlucmakiola.clockula.domain.widget.QuickTimerWidgetPresenter +import de.jeanlucmakiola.clockula.domain.widget.TimerWidgetItem +import de.jeanlucmakiola.clockula.domain.widget.parseQuickTimerDuration +import de.jeanlucmakiola.clockula.timer.QuickTimerIntents +import de.jeanlucmakiola.clockula.timer.TimerIntents +import de.jeanlucmakiola.clockula.timer.receiver.QuickTimerReceiver +import de.jeanlucmakiola.clockula.widget.WIDGET_QUICK_TIMER_DURATION_KEY +import de.jeanlucmakiola.clockula.widget.WIDGET_QUICK_TIMER_ID_KEY +import de.jeanlucmakiola.clockula.widget.di.WidgetEntryPoint + +/** + * Reads this instance's duration and started-timer id, and the timers + * themselves, reactively, then renders [QuickTimerWidgetContent]. Collected + * inside the composition for the reason `TimerWidgetRoot` gives. + */ +@Composable +fun QuickTimerWidgetRoot(entryPoint: WidgetEntryPoint, initial: List) { + val prefs = currentState() + val duration = parseQuickTimerDuration(prefs[WIDGET_QUICK_TIMER_DURATION_KEY]) + val timerId = prefs[WIDGET_QUICK_TIMER_ID_KEY] + val timers by remember { entryPoint.timerRepository().timers() }.collectAsState(initial = initial) + val elapsedRealtime = remember(timers, timerId) { entryPoint.elapsedRealtimeClock().elapsedRealtime() } + val model = remember(duration, timerId, timers) { + QuickTimerWidgetPresenter.present(duration, timerId, timers, elapsedRealtime, entryPoint.wallClock().now()) + } + QuickTimerWidgetContent(model, elapsedRealtime.inWholeMilliseconds) +} + +/** + * The quick-timer widget: a Scallop floating on the home screen, like the + * analog faces, that is a timer of its own. At rest it is a play glyph over + * its duration, and tapping it starts a transient timer of that length + * (`timer/receiver/QuickTimerReceiver.kt`). While that timer is active the + * Scallop shows its countdown in the same layout, and tapping it again + * resets (or, once it rings, stops) it — which deletes the timer, so the + * widget is at rest again. [elapsedRealtimeNow] is the + * `SystemClock.elapsedRealtime()` millis the model was resolved at, the + * countdown chronometer's time base. + */ +@Composable +fun QuickTimerWidgetContent(model: QuickTimerWidgetModel, elapsedRealtimeNow: Long) { + val context = LocalContext.current + val size = LocalSize.current + val side = minOf(size.width, size.height) + val s = side.value + val active = model.active + val onClick = if (active == null) { + val appWidgetId = GlanceAppWidgetManager(context).getAppWidgetId(LocalGlanceId.current) + actionSendBroadcast(startIntent(context, model, appWidgetId)) + } else { + // Reset is a transient timer's dismissal: it stops (or stops ringing) + // and is deleted, so the widget is at rest again. + actionSendBroadcast(timerActionIntent(context, TimerIntents.ACTION_RESET, active.id)) + } + + CenteredSquare(side = side, onClick = onClick) { + val shape = GlanceModifier + .fillMaxSize() + .background(ImageProvider(R.drawable.widget_shape_scallop), colorFilter = ColorFilter.tint(GlanceTheme.colors.primaryContainer)) + if (active == null) { + AtRest(context, model, s, shape) + } else { + Running(context, active, elapsedRealtimeNow, s, shape) + } + } +} + +/** At rest: a play glyph over the duration it starts. */ +@Composable +private fun AtRest(context: Context, model: QuickTimerWidgetModel, s: Float, shape: GlanceModifier) { + val label = ClockFormat.countdown(model.duration) + GlyphOverTime( + icon = R.drawable.widget_ic_play, + description = context.getString(R.string.widget_quick_timer_start, label), + s = s, + shape = shape, + ) { size -> WidgetReadout(label, size, R.color.widget_on_primary_container) } +} + +/** + * Its timer active: the same layout as [AtRest] — the glyph says what a tap + * does now (reset, or stop once it rings), over the live countdown. + */ +@Composable +private fun Running(context: Context, item: TimerWidgetItem, elapsedRealtimeNow: Long, s: Float, shape: GlanceModifier) { + val expired = item.mode == TimerMode.EXPIRED + GlyphOverTime( + icon = if (expired) R.drawable.widget_ic_stop else R.drawable.widget_ic_replay, + description = context.getString(if (expired) R.string.timer_stop else R.string.timer_reset), + s = s, + shape = shape, + ) { size -> + if (item.mode == TimerMode.RUNNING) { + WidgetChronometer( + layoutRes = R.layout.widget_chronometer_on_primary, + viewId = R.id.widget_chronometer_on_primary, + base = elapsedRealtimeNow + item.remaining.inWholeMilliseconds, + countDown = true, + textSizeDp = size, + ) + } else { + val text = if (expired) context.getString(R.string.widget_timer_expired) else ClockFormat.countdown(item.remaining) + // Shrunk to the same width should the word be longer than a time. + WidgetReadout(text, size * minOf(1f, 5f / text.length), R.color.widget_on_primary_container) + } + } +} + +/** + * The one layout both states share: [icon] over a time readout, sized for + * five characters ("01:02", what a running chronometer shows) so the readout + * keeps its size from rest to running and fits the shape's inner ~60%. + */ +@Composable +private fun GlyphOverTime( + icon: Int, + description: String, + s: Float, + shape: GlanceModifier, + readout: @Composable (sizeDp: Float) -> Unit, +) { + Column( + modifier = shape.semantics { contentDescription = description }, + horizontalAlignment = Alignment.CenterHorizontally, + verticalAlignment = Alignment.CenterVertically, + ) { + Image( + provider = ImageProvider(icon), + contentDescription = null, + modifier = GlanceModifier.size((s * 0.26f).dp), + colorFilter = ColorFilter.tint(GlanceTheme.colors.onPrimaryContainer), + ) + readout(minOf(s * 0.2f, s * 0.6f / (5 * 0.6f))) + } +} + +private fun startIntent(context: Context, model: QuickTimerWidgetModel, appWidgetId: Int): Intent = + Intent(context, QuickTimerReceiver::class.java) + .setAction(QuickTimerIntents.ACTION_START) + .putExtra(QuickTimerIntents.EXTRA_DURATION_MILLIS, model.duration.inWholeMilliseconds) + .putExtra(QuickTimerIntents.EXTRA_APP_WIDGET_ID, appWidgetId) diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/ui/widget/StopwatchWidgetContent.kt b/app/src/main/java/de/jeanlucmakiola/clockula/ui/widget/StopwatchWidgetContent.kt new file mode 100644 index 0000000..fd80ab0 --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/clockula/ui/widget/StopwatchWidgetContent.kt @@ -0,0 +1,188 @@ +package de.jeanlucmakiola.clockula.ui.widget + +import android.content.Context +import android.content.Intent +import androidx.compose.runtime.Composable +import androidx.compose.ui.unit.dp +import androidx.glance.GlanceModifier +import androidx.glance.GlanceTheme +import androidx.glance.LocalContext +import androidx.glance.LocalSize +import androidx.glance.appwidget.action.actionSendBroadcast +import androidx.glance.appwidget.action.actionStartActivity +import androidx.glance.layout.Alignment +import androidx.glance.layout.Column +import androidx.glance.layout.Row +import androidx.glance.layout.Spacer +import androidx.glance.layout.fillMaxSize +import androidx.glance.layout.height +import androidx.glance.layout.padding +import androidx.glance.layout.width +import androidx.glance.text.FontWeight +import androidx.glance.text.Text +import androidx.glance.text.TextStyle +import de.jeanlucmakiola.clockula.MainActivity +import de.jeanlucmakiola.clockula.R +import de.jeanlucmakiola.clockula.domain.StopwatchState +import de.jeanlucmakiola.clockula.domain.format.ClockFormat +import de.jeanlucmakiola.clockula.domain.widget.StopwatchWidgetModel +import de.jeanlucmakiola.clockula.stopwatch.StopwatchIntents +import de.jeanlucmakiola.clockula.stopwatch.receiver.StopwatchActionReceiver +import kotlin.time.Duration.Companion.milliseconds +import kotlinx.coroutines.flow.map +import de.jeanlucmakiola.clockula.widget.di.WidgetEntryPoint +import de.jeanlucmakiola.clockula.domain.widget.StopwatchWidgetPresenter +import de.jeanlucmakiola.clockula.domain.StopwatchRun +import androidx.compose.runtime.remember +import androidx.compose.runtime.getValue +import androidx.compose.runtime.collectAsState +import android.os.SystemClock + +/** + * Reads the stopwatch run and its laps reactively, then renders [StopwatchWidgetContent]. + * Collected *inside* the composition: `update()` on a still-live session (one + * lives ~45s) only recomposes, so a value read once before `provideContent` + * would keep showing what was true when the session started — the + * `initial` value only makes the first frame right. + */ +@Composable +fun StopwatchWidgetRoot(entryPoint: WidgetEntryPoint, initialRun: StopwatchRun, initialLapCount: Int) { + val repository = entryPoint.stopwatchRepository() + val run by remember { repository.run() }.collectAsState(initial = initialRun) + val lapCount by remember { repository.laps().map { it.size } }.collectAsState(initial = initialLapCount) + // The same instant the model is resolved at, so the chronometer's base + // is exactly `now - elapsed`. + val elapsedRealtime = remember(run, lapCount) { SystemClock.elapsedRealtime() } + val model = remember(run, lapCount) { StopwatchWidgetPresenter.present(run, elapsedRealtime.milliseconds, lapCount) } + StopwatchWidgetContent(model, elapsedRealtime) +} + +/** + * The stopwatch widget's content, given an already-resolved [model]: the + * readout as large as the widget allows, a wide pill for start/pause and a + * round lap/reset beside it. Below [WIDE_BELOW_HEIGHT] the controls move + * beside the readout instead of under it. No 1s ticker — a running readout + * is the platform chronometer, based at [elapsedRealtimeNow] (the + * `SystemClock.elapsedRealtime()` millis the model was resolved at). + */ +@Composable +fun StopwatchWidgetContent(model: StopwatchWidgetModel, elapsedRealtimeNow: Long) { + val context = LocalContext.current + val size = LocalSize.current + val w = size.width.value + val h = size.height.value + val pad = (minOf(w, h) * 0.1f).coerceIn(10f, 18f) + val innerW = w - 2 * pad + val innerH = h - 2 * pad + // The chronometer reads "00:00" (or "H:MM:SS"), never shorter. + val readoutChars = maxOf(5, ClockFormat.elapsed(model.elapsed).length) + + WidgetCard(onClick = actionStartActivity(showStopwatchIntent(context))) { + if (h < WIDE_BELOW_HEIGHT) { + val button = (innerH * 0.7f).coerceIn(32f, 60f) + val controlsW = button * 1.6f + button * 1.18f + val timeSize = minOf(innerH * 0.62f, (innerW - controlsW) / (readoutChars * 0.6f)).coerceAtLeast(14f) + Row(modifier = GlanceModifier.fillMaxSize().padding(pad.dp), verticalAlignment = Alignment.CenterVertically) { + Column(modifier = GlanceModifier.defaultWeight()) { + Readout(context, model, elapsedRealtimeNow, timeSize) + } + Controls(context, model, button) + } + } else { + val button = (innerH * 0.3f).coerceIn(32f, 64f) + val gap = button * 0.3f + val caption = (button * 0.3f).coerceIn(11f, 16f) + // A Glance `Text` line, font padding included, is ~1.5x its size; + // the readout (no padding) ~1.2x. A little slack keeps rounding + // from clipping the buttons. + val captionH = if (model.lapCount > 0) caption * 1.5f else 0f + val timeSize = minOf((innerH * 0.94f - button - gap - captionH) / 1.2f, innerW / (readoutChars * 0.6f)).coerceAtLeast(14f) + Column( + modifier = GlanceModifier.fillMaxSize().padding(pad.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalAlignment = Alignment.CenterVertically, + ) { + Readout(context, model, elapsedRealtimeNow, timeSize) + if (model.lapCount > 0) { + Text( + text = context.getString(R.string.widget_stopwatch_laps, model.lapCount), + style = TextStyle(color = GlanceTheme.colors.primary, fontSize = fittedSp(context, caption), fontWeight = FontWeight.Medium), + maxLines = 1, + ) + } + Spacer(GlanceModifier.height(gap.dp)) + Controls(context, model, button) + } + } + } +} + +/** Under this height (one launcher row, give or take) the controls sit beside the readout. */ +private const val WIDE_BELOW_HEIGHT = 120f + +@Composable +private fun Readout(context: Context, model: StopwatchWidgetModel, elapsedRealtimeNow: Long, sizeDp: Float) { + if (model.mode == StopwatchState.RUNNING) { + WidgetChronometer( + layoutRes = R.layout.widget_chronometer, + viewId = R.id.widget_chronometer, + base = elapsedRealtimeNow - model.elapsed.inWholeMilliseconds, + countDown = false, + textSizeDp = sizeDp, + ) + } else { + WidgetReadout( + text = ClockFormat.elapsed(model.elapsed), + textSizeDp = sizeDp, + colorRes = if (model.mode == StopwatchState.IDLE) R.color.widget_on_surface_variant else R.color.widget_on_surface, + ) + } +} + +/** A wide primary pill (start/pause) and, once the stopwatch has run, a round lap/reset beside it. */ +@Composable +private fun Controls(context: Context, model: StopwatchWidgetModel, button: Float) { + Row(verticalAlignment = Alignment.CenterVertically) { + val running = model.mode == StopwatchState.RUNNING + ShapedIcon( + icon = if (running) R.drawable.widget_ic_pause else R.drawable.widget_ic_play, + contentDescription = context.getString( + when (model.mode) { + StopwatchState.IDLE -> R.string.stopwatch_start + StopwatchState.RUNNING -> R.string.stopwatch_pause + StopwatchState.PAUSED -> R.string.stopwatch_resume + }, + ), + width = (button * 1.6f).dp, + height = button.dp, + container = GlanceTheme.colors.primary, + content = GlanceTheme.colors.onPrimary, + shape = R.drawable.widget_bg_pill, + onClick = actionSendBroadcast( + actionIntent(context, if (running) StopwatchIntents.ACTION_PAUSE else StopwatchIntents.ACTION_RESUME), + ), + ) + if (model.mode != StopwatchState.IDLE) { + Spacer(GlanceModifier.width((button * 0.18f).dp)) + ShapedIcon( + icon = if (running) R.drawable.widget_ic_flag else R.drawable.widget_ic_replay, + contentDescription = context.getString(if (running) R.string.stopwatch_lap else R.string.stopwatch_reset), + width = button.dp, + height = button.dp, + container = GlanceTheme.colors.secondaryContainer, + content = GlanceTheme.colors.onSecondaryContainer, + onClick = actionSendBroadcast( + actionIntent(context, if (running) StopwatchIntents.ACTION_LAP else StopwatchIntents.ACTION_RESET), + ), + ) + } + } +} + +private fun actionIntent(context: Context, action: String): Intent = + Intent(context, StopwatchActionReceiver::class.java).setAction(action) + +private fun showStopwatchIntent(context: Context): Intent = + Intent(context, MainActivity::class.java) + .setAction(StopwatchIntents.ACTION_SHOW_STOPWATCH) + .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/ui/widget/TimerWidgetContent.kt b/app/src/main/java/de/jeanlucmakiola/clockula/ui/widget/TimerWidgetContent.kt index 15be7ec..b8f70fe 100644 --- a/app/src/main/java/de/jeanlucmakiola/clockula/ui/widget/TimerWidgetContent.kt +++ b/app/src/main/java/de/jeanlucmakiola/clockula/ui/widget/TimerWidgetContent.kt @@ -2,31 +2,27 @@ package de.jeanlucmakiola.clockula.ui.widget import android.content.Context import android.content.Intent -import android.widget.RemoteViews import androidx.compose.runtime.Composable import androidx.compose.ui.unit.dp import androidx.glance.GlanceModifier import androidx.glance.GlanceTheme import androidx.glance.LocalContext -import androidx.glance.text.Text -import androidx.glance.action.clickable -import androidx.glance.appwidget.AndroidRemoteViews +import androidx.glance.LocalSize import androidx.glance.appwidget.action.actionSendBroadcast import androidx.glance.appwidget.action.actionStartActivity -import androidx.glance.appwidget.cornerRadius -import androidx.glance.background import androidx.glance.layout.Alignment -import androidx.glance.layout.Box import androidx.glance.layout.Column import androidx.glance.layout.Row +import androidx.glance.layout.Spacer import androidx.glance.layout.fillMaxSize import androidx.glance.layout.fillMaxWidth -import androidx.glance.layout.padding -import androidx.glance.layout.Spacer import androidx.glance.layout.height +import androidx.glance.layout.padding +import androidx.glance.layout.width import androidx.glance.text.FontWeight +import androidx.glance.text.Text import androidx.glance.text.TextStyle -import androidx.glance.unit.ColorProvider +import de.jeanlucmakiola.clockula.MainActivity import de.jeanlucmakiola.clockula.R import de.jeanlucmakiola.clockula.domain.format.ClockFormat import de.jeanlucmakiola.clockula.domain.timer.TimerMode @@ -35,107 +31,234 @@ import de.jeanlucmakiola.clockula.domain.widget.TimerWidgetModel import de.jeanlucmakiola.clockula.interop.InteropIntents import de.jeanlucmakiola.clockula.timer.TimerIntents import de.jeanlucmakiola.clockula.timer.receiver.TimerActionReceiver -import de.jeanlucmakiola.clockula.MainActivity +import de.jeanlucmakiola.clockula.widget.di.WidgetEntryPoint +import de.jeanlucmakiola.clockula.domain.widget.TimerWidgetPresenter +import de.jeanlucmakiola.clockula.domain.Timer +import androidx.compose.runtime.remember +import androidx.compose.runtime.getValue +import androidx.compose.runtime.collectAsState -/** The timer widget's content, given an already-resolved [model]. No 1s ticker — a running row's countdown is the platform chronometer (as `TimerNotifications.kt`). */ +/** + * Reads the timers reactively, then renders [TimerWidgetContent]. + * Collected *inside* the composition: `update()` on a still-live session (one + * lives ~45s) only recomposes, so a value read once before `provideContent` + * would keep showing what was true when the session started — the + * `initial` value only makes the first frame right. + */ @Composable -fun TimerWidgetContent(model: TimerWidgetModel) { +fun TimerWidgetRoot(entryPoint: WidgetEntryPoint, initial: List) { + val timers by remember { entryPoint.timerRepository().timers() }.collectAsState(initial = initial) + val elapsedRealtime = remember(timers) { entryPoint.elapsedRealtimeClock().elapsedRealtime() } + val model = remember(timers) { + TimerWidgetPresenter.present(timers = timers, elapsedRealtime = elapsedRealtime, wallClock = entryPoint.wallClock().now()) + } + TimerWidgetContent(model, elapsedRealtime.inWholeMilliseconds) +} + +/** Below this a row's readout and buttons get too small to read or hit, so fewer rows show. */ +private const val MIN_ROW_HEIGHT = 56f + +/** + * The timer widget's content, given an already-resolved [model]. No 1s + * ticker — a running row's countdown is the platform chronometer (as + * `TimerNotifications.kt`), based at [elapsedRealtimeNow], the + * `SystemClock.elapsedRealtime()` millis the model was resolved at. + */ +@Composable +fun TimerWidgetContent(model: TimerWidgetModel, elapsedRealtimeNow: Long) { val context = LocalContext.current - GlanceTheme(WidgetDefaults.Colors) { - Box( - modifier = GlanceModifier - .fillMaxSize() - .background(GlanceTheme.colors.widgetBackground) - .cornerRadius(WidgetDefaults.CornerRadius) - .padding(WidgetDefaults.ContentPadding) - .clickable(actionStartActivity(showTimersOrComposeIntent(context, model))), - ) { - if (model.items.isEmpty()) { - Box(modifier = GlanceModifier.fillMaxSize(), contentAlignment = Alignment.Center) { - Text( - text = context.getString(R.string.widget_timer_new), - style = TextStyle(color = GlanceTheme.colors.onSurface, fontWeight = FontWeight.Medium), - ) - } - } else { - Column(modifier = GlanceModifier.fillMaxSize()) { - model.items.forEachIndexed { index, item -> - if (index > 0) Spacer(modifier = GlanceModifier.height(WidgetDefaults.ContentPadding)) - TimerRow(context, item) - } + val size = LocalSize.current + val w = size.width.value + val h = size.height.value + val pad = (minOf(w, h) * 0.1f).coerceIn(10f, 18f) + val innerW = w - 2 * pad + val innerH = h - 2 * pad + + WidgetCard(onClick = actionStartActivity(showTimersOrComposeIntent(context, model))) { + if (model.items.isEmpty()) { + NewTimer(context, innerW, innerH) + } else if (model.items.size == 1 && h >= STACK_FROM_HEIGHT) { + SingleTimer(context, model.items.single(), elapsedRealtimeNow, innerW, innerH, pad) + } else { + val visible = model.items.take((innerH / MIN_ROW_HEIGHT).toInt().coerceAtLeast(1)) + val rowGap = pad * 0.5f + val rowH = (innerH - rowGap * (visible.size - 1)) / visible.size + Column(modifier = GlanceModifier.fillMaxSize().padding(pad.dp), verticalAlignment = Alignment.CenterVertically) { + visible.forEachIndexed { index, item -> + if (index > 0) Spacer(GlanceModifier.height(rowGap.dp)) + TimerRow(context, item, elapsedRealtimeNow, innerW, rowH) } } } } } +/** From this height a lone timer gets the stopwatch's layout — a big readout over its buttons — instead of a row. */ +private const val STACK_FROM_HEIGHT = 120f + +/** One timer on a tall card: label, a readout as large as fits, and its buttons under it. */ @Composable -private fun TimerRow(context: Context, item: TimerWidgetItem) { +private fun SingleTimer(context: Context, item: TimerWidgetItem, elapsedRealtimeNow: Long, innerW: Float, innerH: Float, pad: Float) { + val button = (innerH * 0.3f).coerceIn(32f, 64f) + val gap = button * 0.3f + val hasLabel = item.label.isNotBlank() + val label = (button * 0.3f).coerceIn(11f, 16f) + val labelH = if (hasLabel) label * 1.5f else 0f + val readout = staticReadout(context, item) + val chars = if (item.mode == TimerMode.RUNNING) maxOf(5, readout.length) else readout.length + val timeSize = minOf((innerH * 0.94f - button - gap - labelH) / 1.2f, innerW / (chars * 0.6f)).coerceAtLeast(14f) + Column( + modifier = GlanceModifier.fillMaxSize().padding(pad.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalAlignment = Alignment.CenterVertically, + ) { + if (hasLabel) { + Text( + text = item.label, + style = TextStyle(color = GlanceTheme.colors.onSurfaceVariant, fontSize = fittedSp(context, label), fontWeight = FontWeight.Medium), + maxLines = 1, + ) + } + Readout(context, item, elapsedRealtimeNow, timeSize) + Spacer(GlanceModifier.height(gap.dp)) + Row(verticalAlignment = Alignment.CenterVertically) { Buttons(context, item, button) } + } +} + +/** The idle state: a Cookie "+" that, like the whole card, opens a new timer. */ +@Composable +private fun NewTimer(context: Context, innerW: Float, innerH: Float) { + val wide = innerW >= innerH * 1.6f + val badge = (if (wide) innerH else minOf(innerW, innerH) * 0.62f).coerceAtMost(96f) + val text = context.getString(R.string.widget_timer_new) + val textStyle = TextStyle( + color = GlanceTheme.colors.onSurface, + fontSize = fittedSp(context, (badge * 0.26f).coerceIn(12f, 22f)), + fontWeight = FontWeight.Medium, + ) + val icon = @Composable { + ShapedIcon( + icon = R.drawable.widget_ic_add, + contentDescription = null, + width = badge.dp, + height = badge.dp, + container = GlanceTheme.colors.primaryContainer, + content = GlanceTheme.colors.onPrimaryContainer, + shape = R.drawable.widget_shape_cookie, + ) + } + if (wide) { + Row(verticalAlignment = Alignment.CenterVertically) { + icon() + Spacer(GlanceModifier.width((badge * 0.22f).dp)) + Text(text, style = textStyle, maxLines = 1) + } + } else { + Column(horizontalAlignment = Alignment.CenterHorizontally) { + icon() + Spacer(GlanceModifier.height((badge * 0.12f).dp)) + Text(text, style = textStyle, maxLines = 1) + } + } +} + +@Composable +private fun TimerRow(context: Context, item: TimerWidgetItem, elapsedRealtimeNow: Long, rowW: Float, rowH: Float) { + val button = minOf(rowH * 0.8f, rowW * 0.2f, 60f).coerceAtLeast(28f) + val buttonGap = button * 0.18f + val buttons = if (item.mode == TimerMode.EXPIRED) 1 else 2 + val textW = rowW - buttons * button - (buttons - 1) * buttonGap - button * 0.25f + val hasLabel = item.label.isNotBlank() + val readout = staticReadout(context, item) + // A running countdown's chronometer reads "01:02", never shorter. + val chars = if (item.mode == TimerMode.RUNNING) maxOf(5, readout.length) else readout.length + val timeSize = minOf(rowH * (if (hasLabel) 0.5f else 0.62f), textW / (chars * 0.6f)).coerceAtLeast(14f) + val labelSize = (timeSize * 0.42f).coerceIn(10f, 16f) + Row(modifier = GlanceModifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) { Column(modifier = GlanceModifier.defaultWeight()) { - if (item.label.isNotBlank()) { - Text(item.label, style = TextStyle(color = GlanceTheme.colors.onSurfaceVariant, fontSize = WidgetDefaults.BodyTextSize)) + if (hasLabel) { + Text( + text = item.label, + style = TextStyle(color = GlanceTheme.colors.onSurfaceVariant, fontSize = fittedSp(context, labelSize), fontWeight = FontWeight.Medium), + maxLines = 1, + ) } - TimerReadout(context, item) + Readout(context, item, elapsedRealtimeNow, timeSize) } - TimerActions(context, item) + Spacer(GlanceModifier.width((button * 0.25f).dp)) + Buttons(context, item, button) } } @Composable -private fun TimerReadout(context: Context, item: TimerWidgetItem) { - val endsAt = item.endsAtWallClock - if (item.mode == TimerMode.RUNNING && endsAt != null) { - val views = RemoteViews(context.packageName, R.layout.widget_chronometer).apply { - setChronometer(R.id.widget_chronometer, endsAt.toEpochMilliseconds(), null, true) - setChronometerCountDown(R.id.widget_chronometer, true) - } - AndroidRemoteViews(remoteViews = views) +private fun Readout(context: Context, item: TimerWidgetItem, elapsedRealtimeNow: Long, timeSize: Float) { + if (item.mode == TimerMode.RUNNING) { + WidgetChronometer( + layoutRes = R.layout.widget_chronometer, + viewId = R.id.widget_chronometer, + base = elapsedRealtimeNow + item.remaining.inWholeMilliseconds, + countDown = true, + textSizeDp = timeSize, + ) } else { - Text(text = staticReadout(context, item), style = timeStyle(GlanceTheme.colors.onSurface)) + WidgetReadout( + text = staticReadout(context, item), + textSizeDp = timeSize, + colorRes = if (item.mode == TimerMode.EXPIRED) R.color.widget_tertiary else R.color.widget_on_surface, + ) } } +/** The primary action (pause / play / stop) and, unless expired, reset. Laid out by the caller's `Row`. */ +@Composable +private fun Buttons(context: Context, item: TimerWidgetItem, button: Float) { + val buttonGap = button * 0.18f + when (item.mode) { + TimerMode.RUNNING -> PrimaryButton(R.drawable.widget_ic_pause, context.getString(R.string.timer_pause), pauseIntent(context, item.id), button, R.drawable.widget_bg_circle) + // Expressive shape change: a paused timer's play button is a + // rounded square, a running one's pause button a circle. + TimerMode.PAUSED -> PrimaryButton(R.drawable.widget_ic_play, context.getString(R.string.timer_resume), resumeIntent(context, item.id), button, R.drawable.widget_shape_square) + else -> PrimaryButton(R.drawable.widget_ic_stop, context.getString(R.string.timer_stop), resetIntent(context, item.id), button, R.drawable.widget_shape_square) + } + if (item.mode != TimerMode.EXPIRED) { + Spacer(GlanceModifier.width(buttonGap.dp)) + ShapedIcon( + icon = R.drawable.widget_ic_replay, + contentDescription = context.getString(R.string.timer_reset), + width = button.dp, + height = button.dp, + container = GlanceTheme.colors.secondaryContainer, + content = GlanceTheme.colors.onSecondaryContainer, + onClick = actionSendBroadcast(resetIntent(context, item.id)), + ) + } +} + +@Composable +private fun PrimaryButton(icon: Int, description: String, intent: Intent, size: Float, shape: Int) { + ShapedIcon( + icon = icon, + contentDescription = description, + width = size.dp, + height = size.dp, + container = GlanceTheme.colors.primary, + content = GlanceTheme.colors.onPrimary, + shape = shape, + onClick = actionSendBroadcast(intent), + ) +} + private fun staticReadout(context: Context, item: TimerWidgetItem): String = when (item.mode) { TimerMode.EXPIRED -> context.getString(R.string.widget_timer_expired) else -> ClockFormat.countdown(item.remaining) } -@Composable -private fun TimerActions(context: Context, item: TimerWidgetItem) { - Row { - when (item.mode) { - TimerMode.RUNNING -> ActionButton(context.getString(R.string.timer_pause), pauseIntent(context, item.id)) - TimerMode.PAUSED -> ActionButton(context.getString(R.string.timer_resume), resumeIntent(context, item.id)) - else -> Unit - } - ActionButton( - text = if (item.mode == TimerMode.EXPIRED) { - context.getString(R.string.timer_stop) - } else { - context.getString(R.string.timer_reset) - }, - intent = resetIntent(context, item.id), - ) - } -} - -@Composable -private fun ActionButton(text: String, intent: Intent) { - Text( - text = text, - style = TextStyle(color = GlanceTheme.colors.primary, fontSize = WidgetDefaults.BodyTextSize), - modifier = GlanceModifier - .padding(horizontal = 8.dp, vertical = 4.dp) - .clickable(actionSendBroadcast(intent)), - ) -} - private fun pauseIntent(context: Context, timerId: Long) = timerActionIntent(context, TimerIntents.ACTION_PAUSE, timerId) private fun resumeIntent(context: Context, timerId: Long) = timerActionIntent(context, TimerIntents.ACTION_RESUME, timerId) private fun resetIntent(context: Context, timerId: Long) = timerActionIntent(context, TimerIntents.ACTION_RESET, timerId) -private fun timerActionIntent(context: Context, action: String, timerId: Long): Intent = +internal fun timerActionIntent(context: Context, action: String, timerId: Long): Intent = Intent(context, TimerActionReceiver::class.java) .setAction(action) .putExtra(TimerIntents.EXTRA_TIMER_ID, timerId) @@ -146,6 +269,3 @@ private fun showTimersOrComposeIntent(context: Context, model: TimerWidgetModel) .setAction(action) .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) } - -private fun timeStyle(color: ColorProvider) = - TextStyle(color = color, fontSize = WidgetDefaults.PrimaryTextSize, fontWeight = FontWeight.Medium) diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/ui/widget/WidgetDefaults.kt b/app/src/main/java/de/jeanlucmakiola/clockula/ui/widget/WidgetDefaults.kt deleted file mode 100644 index 25bebb2..0000000 --- a/app/src/main/java/de/jeanlucmakiola/clockula/ui/widget/WidgetDefaults.kt +++ /dev/null @@ -1,29 +0,0 @@ -package de.jeanlucmakiola.clockula.ui.widget - -import androidx.compose.ui.unit.Dp -import androidx.compose.ui.unit.TextUnit -import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp -import androidx.glance.color.ColorProviders -import androidx.glance.material3.ColorProviders as glanceM3ColorProviders -import de.jeanlucmakiola.clockula.ui.theme.ClockulaDarkFallback -import de.jeanlucmakiola.clockula.ui.theme.ClockulaLightFallback - -/** Shared sizing for the three widgets' content, kept in one place like the app's own theme. */ -object WidgetDefaults { - val CornerRadius: Dp = 24.dp - val ContentPadding: Dp = 16.dp - val PrimaryTextSize: TextUnit = 28.sp - val BodyTextSize: TextUnit = 14.sp - val CaptionTextSize: TextUnit = 12.sp - - /** - * [GlanceTheme]'s own M3 bridge: dynamic colour on API 31+, [ClockulaLightFallback]/ - * [ClockulaDarkFallback] otherwise — the same fallback schemes `ui/theme/Theme.kt` - * uses for ordinary Compose (`docs/PLAN.md` §9). - */ - val Colors: ColorProviders = glanceM3ColorProviders( - light = ClockulaLightFallback, - dark = ClockulaDarkFallback, - ) -} diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/ui/widget/WidgetKit.kt b/app/src/main/java/de/jeanlucmakiola/clockula/ui/widget/WidgetKit.kt new file mode 100644 index 0000000..d4894f6 --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/clockula/ui/widget/WidgetKit.kt @@ -0,0 +1,185 @@ +package de.jeanlucmakiola.clockula.ui.widget + +import android.content.Context +import android.os.Build +import android.util.TypedValue +import android.widget.RemoteViews +import androidx.annotation.ColorRes +import androidx.annotation.DrawableRes +import androidx.annotation.LayoutRes +import androidx.compose.runtime.Composable +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.TextUnit +import androidx.compose.ui.unit.sp +import androidx.core.os.ConfigurationCompat +import androidx.glance.ColorFilter +import androidx.glance.GlanceModifier +import androidx.glance.GlanceTheme +import androidx.glance.Image +import androidx.glance.ImageProvider +import androidx.glance.LocalContext +import androidx.glance.action.Action +import androidx.glance.action.clickable +import androidx.glance.appwidget.AndroidRemoteViews +import androidx.glance.appwidget.appWidgetBackground +import androidx.glance.appwidget.cornerRadius +import androidx.glance.background +import androidx.glance.layout.Alignment +import androidx.glance.layout.Box +import androidx.glance.layout.fillMaxSize +import androidx.glance.layout.size +import androidx.glance.layout.wrapContentSize +import androidx.glance.unit.ColorProvider +import de.jeanlucmakiola.clockula.R +import de.jeanlucmakiola.clockula.ui.theme.ClockulaDarkFallback +import de.jeanlucmakiola.clockula.ui.theme.ClockulaLightFallback +import de.jeanlucmakiola.floret.glance.FloretGlanceTheme +import de.jeanlucmakiola.floret.glance.WidgetThemeMode +import java.util.Locale + +/* + * The pieces every widget but the analog one is built from — the same + * patterns `AnalogClockWidgetContent.kt` set: + * - colour follows the launcher's day/night and, on API 31+, the wallpaper + * by itself ([WidgetTheme]) — no app-side theme pinning; + * - containers are M3 shapes (`widget_bg_*`, `widget_shape_*`), white + * drawables tinted here; + * - every size is a fraction of the widget's live `LocalSize` (`SizeMode.Exact` + * on each widget), so text and controls grow and shrink with the widget + * instead of sitting at a fixed sp in a corner of it. + */ + +/** + * The widgets' colours: on API 31+ Glance's own dynamic scheme — resource + * colours the launcher resolves against its day/night and the wallpaper, the + * same system tones as the `widget_*` palette the plain-`RemoteViews` faces + * use — and the app's own fallback schemes below that. + */ +@Composable +internal fun WidgetTheme(content: @Composable () -> Unit) { + FloretGlanceTheme( + lightScheme = ClockulaLightFallback, + darkScheme = ClockulaDarkFallback, + dynamicColor = true, + mode = WidgetThemeMode.SYSTEM, + content = content, + ) +} + +/** + * A widget's card: the shared surface colour inside the launcher's own corner + * radius, and [onClick] for the whole of it. + */ +@Composable +internal fun WidgetCard( + onClick: Action, + contentAlignment: Alignment = Alignment.Center, + content: @Composable () -> Unit, +) { + WidgetTheme { + val base = GlanceModifier + .fillMaxSize() + .appWidgetBackground() + .background(ImageProvider(R.drawable.widget_bg_card), colorFilter = ColorFilter.tint(GlanceTheme.colors.widgetBackground)) + // The drawable already draws the corner; this clips the launcher's own + // press ripple to it, where the platform has a radius to clip to. + val clipped = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + base.cornerRadius(android.R.dimen.system_app_widget_background_radius) + } else { + base + } + Box(modifier = clipped.clickable(onClick), contentAlignment = contentAlignment, content = content) + } +} + +/** [icon] centred on a tinted [shape] — an M3 shape for a badge, a circle or pill for a button. */ +@Composable +internal fun ShapedIcon( + @DrawableRes icon: Int, + contentDescription: String?, + width: Dp, + height: Dp, + container: ColorProvider, + content: ColorProvider, + @DrawableRes shape: Int = R.drawable.widget_bg_circle, + iconFraction: Float = 0.5f, + onClick: Action? = null, +) { + val modifier = GlanceModifier + .size(width, height) + .background(ImageProvider(shape), colorFilter = ColorFilter.tint(container)) + Box( + modifier = if (onClick != null) modifier.clickable(onClick) else modifier, + contentAlignment = Alignment.Center, + ) { + Image( + provider = ImageProvider(icon), + contentDescription = contentDescription, + modifier = GlanceModifier.size(minOf(width, height) * iconFraction), + colorFilter = ColorFilter.tint(content), + ) + } +} + +/** + * A platform `Chronometer` ([layoutRes], ticking in the launcher, not the + * app) at a proportional [textSizeDp]. [base] is in + * `SystemClock.elapsedRealtime()` time, as `RemoteViews.setChronometer` wants. + */ +@Composable +internal fun WidgetChronometer(@LayoutRes layoutRes: Int, viewId: Int, base: Long, countDown: Boolean, textSizeDp: Float) { + val context = LocalContext.current + val views = RemoteViews(context.packageName, layoutRes).apply { + setChronometer(viewId, base, null, true) + setChronometerCountDown(viewId, countDown) + setTextViewTextSize(viewId, TypedValue.COMPLEX_UNIT_DIP, textSizeDp) + } + // Without an explicit wrap, Glance lets embedded views fill their + // parent, and the readout pushes everything after it out of the widget. + AndroidRemoteViews(remoteViews = views, modifier = GlanceModifier.wrapContentSize()) +} + +/** + * A stopped readout ([text] at [textSizeDp], in palette colour [colorRes]): + * the same padding-free view as [WidgetChronometer]'s, unlike a Glance + * `Text`, so a readout takes the same height whether it is ticking or not. + */ +@Composable +internal fun WidgetReadout(text: String, textSizeDp: Float, @ColorRes colorRes: Int) { + val context = LocalContext.current + val views = RemoteViews(context.packageName, R.layout.widget_readout).apply { + setTextViewText(R.id.widget_readout, text) + setTextViewTextSize(R.id.widget_readout, TypedValue.COMPLEX_UNIT_DIP, textSizeDp) + // From API 31 the launcher resolves the colour against its own + // day/night; before that it can only be resolved here. + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + setColorStateList(R.id.widget_readout, "setTextColor", colorRes) + } else { + setTextColor(R.id.widget_readout, context.getColor(colorRes)) + } + } + AndroidRemoteViews(remoteViews = views, modifier = GlanceModifier.wrapContentSize()) +} + +/** + * [dp] as a Glance text size that stays [dp] whatever the user's font scale: + * sizes here are already fitted to the widget, and scaling them again would + * overflow it. + */ +internal fun fittedSp(context: Context, dp: Float): TextUnit = (dp / context.resources.configuration.fontScale).sp + +/** A square [Box] of [side], centred in the widget — for faces that are a shape rather than a card. */ +@Composable +internal fun CenteredSquare(side: Dp, onClick: Action, content: @Composable () -> Unit) { + Box( + modifier = GlanceModifier.fillMaxSize().clickable(onClick), + contentAlignment = Alignment.Center, + ) { + WidgetTheme { + Box(modifier = GlanceModifier.size(side), contentAlignment = Alignment.Center, content = content) + } + } +} + +internal fun currentLocale(context: Context): Locale = + ConfigurationCompat.getLocales(context.resources.configuration).get(0) ?: Locale.getDefault() diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/ui/widget/config/AnalogClockConfigActivity.kt b/app/src/main/java/de/jeanlucmakiola/clockula/ui/widget/config/AnalogClockConfigActivity.kt new file mode 100644 index 0000000..4a73f4a --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/clockula/ui/widget/config/AnalogClockConfigActivity.kt @@ -0,0 +1,142 @@ +package de.jeanlucmakiola.clockula.ui.widget.config + +import android.appwidget.AppWidgetManager +import android.content.Intent +import android.os.Bundle +import android.widget.FrameLayout +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.activity.enableEdgeToEdge +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.size +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.compose.ui.viewinterop.AndroidView +import androidx.glance.appwidget.GlanceAppWidgetManager +import androidx.glance.appwidget.state.getAppWidgetState +import androidx.glance.appwidget.state.updateAppWidgetState +import androidx.glance.state.PreferencesGlanceStateDefinition +import androidx.lifecycle.lifecycleScope +import dagger.hilt.android.AndroidEntryPoint +import de.jeanlucmakiola.clockula.R +import de.jeanlucmakiola.clockula.data.prefs.SettingsPrefs +import de.jeanlucmakiola.clockula.domain.widget.AnalogClockStyle +import de.jeanlucmakiola.clockula.domain.widget.parseAnalogClockStyle +import de.jeanlucmakiola.clockula.ui.theme.ClockulaTheme +import de.jeanlucmakiola.clockula.ui.widget.analogClockViews +import de.jeanlucmakiola.clockula.widget.AnalogClockWidget +import de.jeanlucmakiola.clockula.widget.WIDGET_ANALOG_STYLE_KEY +import de.jeanlucmakiola.floret.prefs.Appearance +import de.jeanlucmakiola.floret.prefs.isDark +import kotlinx.coroutines.launch +import javax.inject.Inject + +/** + * The analog widget's entire configuration: a style pick, nothing else — this + * activity *is* [WidgetStylePicker]; tapping an option saves and finishes + * straight away, as the reference picker does (no separate "Save" step). + */ +@AndroidEntryPoint +class AnalogClockConfigActivity : ComponentActivity() { + + @Inject lateinit var settingsPrefs: SettingsPrefs + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + val appWidgetId = intent?.getIntExtra(AppWidgetManager.EXTRA_APPWIDGET_ID, AppWidgetManager.INVALID_APPWIDGET_ID) + ?: AppWidgetManager.INVALID_APPWIDGET_ID + setResult(RESULT_CANCELED, resultIntent(appWidgetId)) + if (appWidgetId == AppWidgetManager.INVALID_APPWIDGET_ID) { + finish() + return + } + // A stale id (the widget removed meanwhile) or one that isn't ours + // throws here; there is nothing to configure, so just close. + val glanceId = runCatching { GlanceAppWidgetManager(this).getGlanceIdBy(appWidgetId) }.getOrNull() + if (glanceId == null) { + finish() + return + } + enableEdgeToEdge() + + setContent { + val appearance by settingsPrefs.appearance.collectAsState(initial = Appearance()) + var style by remember { mutableStateOf(AnalogClockStyle.ROUND) } + LaunchedEffect(glanceId) { + val state = getAppWidgetState(this@AnalogClockConfigActivity, PreferencesGlanceStateDefinition, glanceId) + style = parseAnalogClockStyle(state[WIDGET_ANALOG_STYLE_KEY]) + } + val darkTheme = appearance.themeMode.isDark(isSystemInDarkTheme()) + ClockulaTheme(darkTheme = darkTheme, dynamicColor = appearance.dynamicColor) { + Surface(modifier = Modifier.fillMaxSize(), color = MaterialTheme.colorScheme.surface) {} + WidgetStylePicker( + title = stringResource(R.string.widget_analog_config_title), + options = AnalogClockStyle.entries, + selected = style, + label = { stringResource(analogStyleLabel(it)) }, + preview = { AnalogStylePreview(it) }, + onSelect = { picked -> + style = picked + lifecycleScope.launch { + updateAppWidgetState(this@AnalogClockConfigActivity, glanceId) { prefs -> + prefs[WIDGET_ANALOG_STYLE_KEY] = picked.name + } + AnalogClockWidget().update(this@AnalogClockConfigActivity, glanceId) + setResult(RESULT_OK, resultIntent(appWidgetId)) + finish() + } + }, + onDismiss = { finish() }, + ) + } + } + } + + private fun resultIntent(appWidgetId: Int) = + Intent().putExtra(AppWidgetManager.EXTRA_APPWIDGET_ID, appWidgetId) +} + +internal fun analogStyleLabel(style: AnalogClockStyle): Int = when (style) { + AnalogClockStyle.ROUND -> R.string.analog_style_round + AnalogClockStyle.NUMERAL -> R.string.analog_style_numeral + AnalogClockStyle.BADGE -> R.string.analog_style_badge + AnalogClockStyle.COOKIE -> R.string.analog_style_cookie + AnalogClockStyle.CLOVER -> R.string.analog_style_clover + AnalogClockStyle.PETAL -> R.string.analog_style_petal + AnalogClockStyle.SUNNY -> R.string.analog_style_sunny + AnalogClockStyle.SQUARE -> R.string.analog_style_square + AnalogClockStyle.SCALLOP -> R.string.analog_style_scallop +} + +/** + * The real widget face, not a redrawing: inflates [analogClockViews] — the + * same `RemoteViews` the placed widget renders — in-app, so the `AnalogClock` + * ticks and the dial, numerals and colours match exactly. + */ +@Composable +private fun AnalogStylePreview(style: AnalogClockStyle) { + val context = LocalContext.current + val views = remember(style) { analogClockViews(context, style, PreviewSize.value) } + AndroidView( + factory = { FrameLayout(it) }, + update = { frame -> + frame.removeAllViews() + frame.addView(views.apply(frame.context, frame)) + }, + modifier = Modifier.size(PreviewSize), + ) +} + +private val PreviewSize = 180.dp diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/ui/widget/config/ClockWidgetConfigActivity.kt b/app/src/main/java/de/jeanlucmakiola/clockula/ui/widget/config/ClockWidgetConfigActivity.kt new file mode 100644 index 0000000..3876160 --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/clockula/ui/widget/config/ClockWidgetConfigActivity.kt @@ -0,0 +1,232 @@ +package de.jeanlucmakiola.clockula.ui.widget.config + +import android.appwidget.AppWidgetManager +import android.content.Intent +import android.os.Bundle +import android.widget.FrameLayout +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.activity.enableEdgeToEdge +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Switch +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.produceState +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.compose.ui.viewinterop.AndroidView +import androidx.glance.appwidget.GlanceAppWidgetManager +import androidx.glance.appwidget.state.getAppWidgetState +import androidx.glance.appwidget.state.updateAppWidgetState +import androidx.glance.state.PreferencesGlanceStateDefinition +import androidx.lifecycle.lifecycleScope +import dagger.hilt.android.AndroidEntryPoint +import de.jeanlucmakiola.clockula.R +import de.jeanlucmakiola.clockula.data.prefs.SettingsPrefs +import de.jeanlucmakiola.clockula.data.worldclocks.WorldClockRepository +import de.jeanlucmakiola.clockula.data.zones.ZoneNames +import de.jeanlucmakiola.clockula.domain.widget.DigitalClockSettings +import de.jeanlucmakiola.clockula.domain.widget.DigitalClockStyle +import de.jeanlucmakiola.clockula.domain.widget.showsWorldClocks +import de.jeanlucmakiola.clockula.ui.theme.ClockulaTheme +import de.jeanlucmakiola.clockula.ui.widget.ClockWidgetCity +import de.jeanlucmakiola.clockula.ui.widget.digitalClockViews +import de.jeanlucmakiola.clockula.ui.widget.widgetCities +import de.jeanlucmakiola.clockula.widget.ClockWidget +import de.jeanlucmakiola.clockula.widget.WIDGET_DIGITAL_STYLE_KEY +import de.jeanlucmakiola.clockula.widget.WIDGET_SHOW_DATE_KEY +import de.jeanlucmakiola.clockula.widget.WIDGET_SHOW_WORLD_CLOCKS_KEY +import de.jeanlucmakiola.clockula.widget.parseDigitalClockSettings +import de.jeanlucmakiola.floret.components.FullScreenPicker +import de.jeanlucmakiola.floret.components.GroupedRow +import de.jeanlucmakiola.floret.components.Position +import de.jeanlucmakiola.floret.prefs.Appearance +import de.jeanlucmakiola.floret.prefs.isDark +import kotlinx.coroutines.launch +import javax.inject.Inject + +/** + * Picks the digital widget instance's style and the two switches. Runs when + * the widget is placed (below API 31, or whenever the launcher doesn't honour + * `configuration_optional`) and again from "Edit widget". + */ +@AndroidEntryPoint +class ClockWidgetConfigActivity : ComponentActivity() { + + @Inject lateinit var settingsPrefs: SettingsPrefs + @Inject lateinit var worldClockRepository: WorldClockRepository + @Inject lateinit var zoneNames: ZoneNames + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + val appWidgetId = intent?.getIntExtra(AppWidgetManager.EXTRA_APPWIDGET_ID, AppWidgetManager.INVALID_APPWIDGET_ID) + ?: AppWidgetManager.INVALID_APPWIDGET_ID + // Backing out cancels the placement, as the framework expects. + setResult(RESULT_CANCELED, resultIntent(appWidgetId)) + if (appWidgetId == AppWidgetManager.INVALID_APPWIDGET_ID) { + finish() + return + } + // A stale id (the widget removed meanwhile) or one that isn't ours + // throws here; there is nothing to configure, so just close. + val glanceId = runCatching { GlanceAppWidgetManager(this).getGlanceIdBy(appWidgetId) }.getOrNull() + if (glanceId == null) { + finish() + return + } + enableEdgeToEdge() + + setContent { + val appearance by settingsPrefs.appearance.collectAsState(initial = Appearance()) + var settings by remember { mutableStateOf(DigitalClockSettings()) } + val cities by produceState(initialValue = emptyList()) { + value = widgetCities(worldClockRepository, zoneNames) + } + LaunchedEffect(glanceId) { + val state = getAppWidgetState(this@ClockWidgetConfigActivity, PreferencesGlanceStateDefinition, glanceId) + settings = parseDigitalClockSettings( + style = state[WIDGET_DIGITAL_STYLE_KEY], + showDate = state[WIDGET_SHOW_DATE_KEY], + showWorldClocks = state[WIDGET_SHOW_WORLD_CLOCKS_KEY], + ) + } + val darkTheme = appearance.themeMode.isDark(isSystemInDarkTheme()) + ClockulaTheme(darkTheme = darkTheme, dynamicColor = appearance.dynamicColor) { + Surface(modifier = Modifier.fillMaxSize(), color = MaterialTheme.colorScheme.surface) {} + ClockWidgetConfigScreen( + settings = settings, + cities = cities, + onSettingsChange = { settings = it }, + onSave = { + lifecycleScope.launch { + updateAppWidgetState(this@ClockWidgetConfigActivity, glanceId) { prefs -> + prefs[WIDGET_DIGITAL_STYLE_KEY] = settings.style.name + prefs[WIDGET_SHOW_DATE_KEY] = settings.showDate + prefs[WIDGET_SHOW_WORLD_CLOCKS_KEY] = settings.showWorldClocks + } + ClockWidget().update(this@ClockWidgetConfigActivity, glanceId) + setResult(RESULT_OK, resultIntent(appWidgetId)) + finish() + } + }, + onDismiss = { finish() }, + ) + } + } + } + + private fun resultIntent(appWidgetId: Int) = + Intent().putExtra(AppWidgetManager.EXTRA_APPWIDGET_ID, appWidgetId) +} + +@Composable +private fun ClockWidgetConfigScreen( + settings: DigitalClockSettings, + cities: List, + onSettingsChange: (DigitalClockSettings) -> Unit, + onSave: () -> Unit, + onDismiss: () -> Unit, +) { + var showStylePicker by remember { mutableStateOf(false) } + val showsWorldClocks = settings.style.showsWorldClocks + + FullScreenPicker( + title = stringResource(R.string.widget_config_title), + onDismiss = onDismiss, + actions = { + TextButton(onClick = onSave) { Text(stringResource(android.R.string.ok)) } + }, + ) { + // The widget as it will look, live, above the settings that change it. + Box(modifier = Modifier.fillMaxWidth().padding(vertical = 16.dp), contentAlignment = Alignment.Center) { + DigitalStylePreview(settings, cities) + } + GroupedRow( + title = stringResource(R.string.widget_config_style), + summary = stringResource(digitalStyleLabel(settings.style)), + position = Position.Top, + onClick = { showStylePicker = true }, + ) + GroupedRow( + title = stringResource(R.string.widget_config_show_date), + position = if (showsWorldClocks) Position.Middle else Position.Bottom, + trailing = { Switch(checked = settings.showDate, onCheckedChange = { onSettingsChange(settings.copy(showDate = it)) }) }, + onClick = { onSettingsChange(settings.copy(showDate = !settings.showDate)) }, + ) + // Only the card faces have room for world clocks. + if (showsWorldClocks) { + GroupedRow( + title = stringResource(R.string.widget_config_show_world_clocks), + summary = stringResource(R.string.widget_config_show_world_clocks_hint), + position = Position.Bottom, + trailing = { Switch(checked = settings.showWorldClocks, onCheckedChange = { onSettingsChange(settings.copy(showWorldClocks = it)) }) }, + onClick = { onSettingsChange(settings.copy(showWorldClocks = !settings.showWorldClocks)) }, + ) + } + Spacer(Modifier.height(24.dp)) + } + + if (showStylePicker) { + WidgetStylePicker( + title = stringResource(R.string.widget_config_style), + options = DigitalClockStyle.entries, + selected = settings.style, + label = { stringResource(digitalStyleLabel(it)) }, + preview = { style -> DigitalStylePreview(settings.copy(style = style), cities) }, + onSelect = { onSettingsChange(settings.copy(style = it)) }, + onDismiss = { showStylePicker = false }, + ) + } +} + +internal fun digitalStyleLabel(style: DigitalClockStyle): Int = when (style) { + DigitalClockStyle.REGULAR -> R.string.clock_face_regular + DigitalClockStyle.LIGHT -> R.string.clock_face_light + DigitalClockStyle.BOLD -> R.string.clock_face_bold + DigitalClockStyle.PILL -> R.string.clock_face_pill + DigitalClockStyle.STACKED -> R.string.clock_face_stacked +} + +/** + * The real widget face, not a redrawing: inflates [digitalClockViews] — the + * same `RemoteViews` the placed widget renders — in-app, so the `TextClock`s + * tick and the layout, sizes and colours match exactly. + */ +@Composable +private fun DigitalStylePreview(settings: DigitalClockSettings, cities: List) { + val context = LocalContext.current + val views = remember(settings, cities) { + digitalClockViews(context, settings, cities, PreviewWidth.value, PreviewHeight.value) + } + AndroidView( + factory = { FrameLayout(it) }, + update = { frame -> + frame.removeAllViews() + frame.addView(views.apply(frame.context, frame)) + }, + modifier = Modifier.size(PreviewWidth, PreviewHeight), + ) +} + +/** About a 3x2 widget: wide enough for the card faces, tall enough for a city or two. */ +private val PreviewWidth = 260.dp +private val PreviewHeight = 160.dp diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/ui/widget/config/QuickTimerConfigActivity.kt b/app/src/main/java/de/jeanlucmakiola/clockula/ui/widget/config/QuickTimerConfigActivity.kt new file mode 100644 index 0000000..8940ff5 --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/clockula/ui/widget/config/QuickTimerConfigActivity.kt @@ -0,0 +1,105 @@ +package de.jeanlucmakiola.clockula.ui.widget.config + +import android.appwidget.AppWidgetManager +import android.content.Intent +import android.os.Bundle +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.activity.enableEdgeToEdge +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.glance.appwidget.GlanceAppWidgetManager +import androidx.glance.appwidget.state.getAppWidgetState +import androidx.glance.appwidget.state.updateAppWidgetState +import androidx.glance.state.PreferencesGlanceStateDefinition +import androidx.lifecycle.lifecycleScope +import dagger.hilt.android.AndroidEntryPoint +import de.jeanlucmakiola.clockula.R +import de.jeanlucmakiola.clockula.data.prefs.SettingsPrefs +import de.jeanlucmakiola.clockula.domain.format.ClockFormat +import de.jeanlucmakiola.clockula.domain.timer.TimerPresets +import de.jeanlucmakiola.clockula.domain.widget.QuickTimerWidgetSettings +import de.jeanlucmakiola.clockula.domain.widget.parseQuickTimerDuration +import de.jeanlucmakiola.clockula.ui.theme.ClockulaTheme +import de.jeanlucmakiola.clockula.widget.QuickTimerWidget +import de.jeanlucmakiola.clockula.widget.WIDGET_QUICK_TIMER_DURATION_KEY +import de.jeanlucmakiola.floret.components.OptionPicker +import de.jeanlucmakiola.floret.prefs.Appearance +import de.jeanlucmakiola.floret.prefs.isDark +import kotlinx.coroutines.launch +import javax.inject.Inject + +/** + * The quick-timer widget's entire configuration: which built-in duration it + * starts on tap — reuses `domain/timer/TimerPresets.BUILT_IN`, the same list + * the Timers screen's own quick-start row offers, so this picker never + * invents a second set of durations. Like [AnalogClockConfigActivity], tapping + * an option saves and finishes immediately. + */ +@AndroidEntryPoint +class QuickTimerConfigActivity : ComponentActivity() { + + @Inject lateinit var settingsPrefs: SettingsPrefs + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + val appWidgetId = intent?.getIntExtra(AppWidgetManager.EXTRA_APPWIDGET_ID, AppWidgetManager.INVALID_APPWIDGET_ID) + ?: AppWidgetManager.INVALID_APPWIDGET_ID + setResult(RESULT_CANCELED, resultIntent(appWidgetId)) + if (appWidgetId == AppWidgetManager.INVALID_APPWIDGET_ID) { + finish() + return + } + // A stale id (the widget removed meanwhile) or one that isn't ours + // throws here; there is nothing to configure, so just close. + val glanceId = runCatching { GlanceAppWidgetManager(this).getGlanceIdBy(appWidgetId) }.getOrNull() + if (glanceId == null) { + finish() + return + } + enableEdgeToEdge() + + setContent { + val appearance by settingsPrefs.appearance.collectAsState(initial = Appearance()) + var duration by remember { mutableStateOf(QuickTimerWidgetSettings.DEFAULT) } + LaunchedEffect(glanceId) { + val state = getAppWidgetState(this@QuickTimerConfigActivity, PreferencesGlanceStateDefinition, glanceId) + duration = parseQuickTimerDuration(state[WIDGET_QUICK_TIMER_DURATION_KEY]) + } + val darkTheme = appearance.themeMode.isDark(isSystemInDarkTheme()) + ClockulaTheme(darkTheme = darkTheme, dynamicColor = appearance.dynamicColor) { + Surface(modifier = Modifier.fillMaxSize(), color = MaterialTheme.colorScheme.surface) {} + OptionPicker( + title = stringResource(R.string.widget_quick_timer_config_title), + options = TimerPresets.BUILT_IN, + selected = duration, + label = { ClockFormat.countdown(it) }, + onSelect = { picked -> + lifecycleScope.launch { + updateAppWidgetState(this@QuickTimerConfigActivity, glanceId) { prefs -> + prefs[WIDGET_QUICK_TIMER_DURATION_KEY] = picked.inWholeMilliseconds + } + QuickTimerWidget().update(this@QuickTimerConfigActivity, glanceId) + setResult(RESULT_OK, resultIntent(appWidgetId)) + finish() + } + }, + onDismiss = { finish() }, + ) + } + } + } + + private fun resultIntent(appWidgetId: Int) = + Intent().putExtra(AppWidgetManager.EXTRA_APPWIDGET_ID, appWidgetId) +} diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/ui/widget/config/WidgetStylePicker.kt b/app/src/main/java/de/jeanlucmakiola/clockula/ui/widget/config/WidgetStylePicker.kt new file mode 100644 index 0000000..ab972ab --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/clockula/ui/widget/config/WidgetStylePicker.kt @@ -0,0 +1,66 @@ +package de.jeanlucmakiola.clockula.ui.widget.config + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import de.jeanlucmakiola.floret.components.FullScreenPicker +import de.jeanlucmakiola.floret.components.GroupedListInset + +/** + * A full-bleed style picker: one large, live preview per option with its name + * centred below — the layout a visual choice needs (`FullScreenPicker`'s third, + * richest rung), rather than a row-plus-summary list a reader has to imagine. + * The selected option carries a tonal highlight; tapping any option selects it + * and closes the picker immediately, same as [de.jeanlucmakiola.floret.components.OptionPicker]. + */ +@Composable +fun WidgetStylePicker( + title: String, + options: List, + selected: T, + label: @Composable (T) -> String, + preview: @Composable (T) -> Unit, + onSelect: (T) -> Unit, + onDismiss: () -> Unit, +) { + FullScreenPicker(title = title, onDismiss = onDismiss) { + options.forEach { option -> + val isSelected = option == selected + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = GroupedListInset, vertical = 10.dp) + .clip(RoundedCornerShape(28.dp)) + .let { if (isSelected) it.background(MaterialTheme.colorScheme.secondaryContainer) else it } + .clickable { + onSelect(option) + onDismiss() + } + .padding(16.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + preview(option) + Spacer(Modifier.height(10.dp)) + Text( + text = label(option), + style = MaterialTheme.typography.titleMedium, + fontWeight = if (isSelected) FontWeight.Bold else FontWeight.Normal, + color = if (isSelected) MaterialTheme.colorScheme.onSecondaryContainer else MaterialTheme.colorScheme.onSurface, + ) + } + } + } +} diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/widget/AnalogClockWidget.kt b/app/src/main/java/de/jeanlucmakiola/clockula/widget/AnalogClockWidget.kt new file mode 100644 index 0000000..8599610 --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/clockula/widget/AnalogClockWidget.kt @@ -0,0 +1,37 @@ +package de.jeanlucmakiola.clockula.widget + +import android.content.Context +import androidx.glance.GlanceId +import androidx.glance.appwidget.GlanceAppWidget +import androidx.glance.appwidget.GlanceAppWidgetReceiver +import androidx.glance.appwidget.SizeMode +import androidx.glance.appwidget.provideContent +import de.jeanlucmakiola.clockula.ui.widget.AnalogClockWidgetRoot + +/** + * The analog clock widget. Per-instance style is read reactively inside + * [AnalogClockWidgetRoot] from this instance's own Glance state + * (`widget/ClockWidgetState.kt`), written by `AnalogClockConfigActivity` — + * see `widget/ClockWidget.kt`'s doc comment for why that read happens inside + * the composition rather than once up front here. + * + * [SizeMode.Exact]: the default [SizeMode.Single] gives every session a + * fixed, one-time `LocalSize` no matter how the widget is later resized — + * `ui/widget/AnalogClockWidgetContent.kt` reads `LocalSize` to size the dial + * to the widget's actual placed size, so without this a resize would never + * change what's drawn. + */ +class AnalogClockWidget : GlanceAppWidget() { + + override val sizeMode: SizeMode = SizeMode.Exact + + override suspend fun provideGlance(context: Context, id: GlanceId) { + provideContent { + AnalogClockWidgetRoot() + } + } +} + +class AnalogClockWidgetReceiver : GlanceAppWidgetReceiver() { + override val glanceAppWidget: GlanceAppWidget = AnalogClockWidget() +} diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/widget/ClockWidget.kt b/app/src/main/java/de/jeanlucmakiola/clockula/widget/ClockWidget.kt index af373b6..f27ce70 100644 --- a/app/src/main/java/de/jeanlucmakiola/clockula/widget/ClockWidget.kt +++ b/app/src/main/java/de/jeanlucmakiola/clockula/widget/ClockWidget.kt @@ -4,27 +4,38 @@ import android.content.Context import androidx.glance.GlanceId import androidx.glance.appwidget.GlanceAppWidget import androidx.glance.appwidget.GlanceAppWidgetReceiver +import androidx.glance.appwidget.SizeMode import androidx.glance.appwidget.provideContent -import de.jeanlucmakiola.clockula.domain.widget.ClockWidgetPresenter -import de.jeanlucmakiola.clockula.ui.widget.ClockWidgetCity -import de.jeanlucmakiola.clockula.ui.widget.ClockWidgetContent +import de.jeanlucmakiola.clockula.ui.widget.ClockWidgetRoot import de.jeanlucmakiola.clockula.widget.di.widgetEntryPoint -import kotlinx.coroutines.flow.first -/** The clock widget. City names are resolved through the ICU seam (`ZoneNames`) here, before composing — `ui/widget/` never names `android.icu` itself. */ +/** + * The digital clock widget. Per-instance face and settings are + * read reactively inside [ClockWidgetRoot] (`ui/widget/ClockWidgetContent.kt`) + * from this instance's own Glance state (`widget/ClockWidgetState.kt`), + * written by `ClockWidgetConfigActivity`; an instance placed with + * configuration skipped has none set, which [parseStoredClockWidgetState] + * resolves to the defaults that reproduce the pre-M11 layout. + * + * Reading that state via `currentState()` *inside* the composition — rather + * than once up front here — matters: `GlanceAppWidget.update()` reuses a + * still-live session (one lives ~45s) by recomposing in place rather than + * re-running [provideGlance], so a `val` read here before `provideContent` + * would keep showing whatever was true when the session started, no matter + * how many times the config screen saves afterwards. + */ class ClockWidget : GlanceAppWidget() { + // The default SizeMode.Single would give every session a fixed, one-time + // LocalSize, so `DigitalFaces.kt`'s sizing would never react to an + // actual resize. + override val sizeMode: SizeMode = SizeMode.Exact + override suspend fun provideGlance(context: Context, id: GlanceId) { val entryPoint = widgetEntryPoint(context) - val selected = ClockWidgetPresenter.present(entryPoint.worldClockRepository().worldClocks().first()) - val zoneNames = entryPoint.zoneNames() - val cities = selected.map { clock -> - ClockWidgetCity( - zoneId = clock.zoneId, - cityName = clock.label?.takeIf(String::isNotBlank) ?: zoneNames.cityOf(clock.zoneId) ?: clock.zoneId, - ) + provideContent { + ClockWidgetRoot(entryPoint) } - provideContent { ClockWidgetContent(cities) } } } diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/widget/ClockWidgetState.kt b/app/src/main/java/de/jeanlucmakiola/clockula/widget/ClockWidgetState.kt new file mode 100644 index 0000000..5cf313c --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/clockula/widget/ClockWidgetState.kt @@ -0,0 +1,43 @@ +package de.jeanlucmakiola.clockula.widget + +import androidx.datastore.preferences.core.booleanPreferencesKey +import androidx.datastore.preferences.core.longPreferencesKey +import androidx.datastore.preferences.core.stringPreferencesKey +import de.jeanlucmakiola.clockula.domain.widget.AnalogClockStyle +import de.jeanlucmakiola.clockula.domain.widget.DigitalClockSettings +import de.jeanlucmakiola.clockula.domain.widget.DigitalClockStyle +import de.jeanlucmakiola.clockula.domain.widget.QuickTimerWidgetSettings +import de.jeanlucmakiola.clockula.domain.widget.parseAnalogClockStyle +import de.jeanlucmakiola.clockula.domain.widget.parseDigitalClockStyle +import de.jeanlucmakiola.clockula.domain.widget.parseQuickTimerDuration + +/** Per-instance Glance state key holding the digital widget's [DigitalClockStyle] ([name]). */ +internal val WIDGET_DIGITAL_STYLE_KEY = stringPreferencesKey("digital_style") + +/** Per-instance Glance state key: show the date line below the time. */ +internal val WIDGET_SHOW_DATE_KEY = booleanPreferencesKey("show_date") + +/** Per-instance Glance state key: show up to three world-clock cities below the face. */ +internal val WIDGET_SHOW_WORLD_CLOCKS_KEY = booleanPreferencesKey("show_world_clocks") + +/** Per-instance Glance state key holding the analog widget's [AnalogClockStyle] ([name]). */ +internal val WIDGET_ANALOG_STYLE_KEY = stringPreferencesKey("analog_style") + +/** Per-instance Glance state key holding the quick-timer widget's configured duration, in millis. */ +internal val WIDGET_QUICK_TIMER_DURATION_KEY = longPreferencesKey("quick_timer_duration_millis") + +/** Per-instance Glance state key: the id of the timer this quick-timer widget last started (`timer/receiver/QuickTimerReceiver.kt`). */ +internal val WIDGET_QUICK_TIMER_ID_KEY = longPreferencesKey("quick_timer_timer_id") + +internal fun parseDigitalClockSettings(style: String?, showDate: Boolean?, showWorldClocks: Boolean?): DigitalClockSettings = + DigitalClockSettings( + style = parseDigitalClockStyle(style), + showDate = showDate ?: true, + showWorldClocks = showWorldClocks ?: false, + ) + +internal fun quickTimerSettings(durationMillis: Long?): QuickTimerWidgetSettings = + QuickTimerWidgetSettings(duration = parseQuickTimerDuration(durationMillis)) + +internal val QuickTimerWidgetSettings.durationMillis: Long + get() = duration.inWholeMilliseconds diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/widget/NextAlarmWidget.kt b/app/src/main/java/de/jeanlucmakiola/clockula/widget/NextAlarmWidget.kt index 18f75c7..e54c690 100644 --- a/app/src/main/java/de/jeanlucmakiola/clockula/widget/NextAlarmWidget.kt +++ b/app/src/main/java/de/jeanlucmakiola/clockula/widget/NextAlarmWidget.kt @@ -4,24 +4,23 @@ import android.content.Context import androidx.glance.GlanceId import androidx.glance.appwidget.GlanceAppWidget import androidx.glance.appwidget.GlanceAppWidgetReceiver +import androidx.glance.appwidget.SizeMode import androidx.glance.appwidget.provideContent -import de.jeanlucmakiola.clockula.domain.widget.NextAlarmWidgetPresenter -import de.jeanlucmakiola.clockula.ui.widget.NextAlarmWidgetContent +import de.jeanlucmakiola.clockula.ui.widget.NextAlarmWidgetRoot import de.jeanlucmakiola.clockula.widget.di.widgetEntryPoint import kotlinx.coroutines.flow.first /** The next-alarm widget. No `updatePeriodMillis` — [WidgetSync] refreshes it whenever an alarm changes. */ class NextAlarmWidget : GlanceAppWidget() { + // Content is sized from the live `LocalSize` (`ui/widget/WidgetKit.kt`); + // the default SizeMode.Single would freeze it at the placed size. + override val sizeMode: SizeMode = SizeMode.Exact + override suspend fun provideGlance(context: Context, id: GlanceId) { val entryPoint = widgetEntryPoint(context) - val upcoming = entryPoint.alarmEngine().upcoming().first() - val model = NextAlarmWidgetPresenter.present( - upcoming = upcoming, - now = entryPoint.wallClock().now(), - zone = entryPoint.zoneProvider().current(), - ) - provideContent { NextAlarmWidgetContent(model) } + val initial = entryPoint.alarmEngine().upcoming().first() + provideContent { NextAlarmWidgetRoot(entryPoint, initial) } } } diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/widget/QuickTimerWidget.kt b/app/src/main/java/de/jeanlucmakiola/clockula/widget/QuickTimerWidget.kt new file mode 100644 index 0000000..fc5a5ae --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/clockula/widget/QuickTimerWidget.kt @@ -0,0 +1,36 @@ +package de.jeanlucmakiola.clockula.widget + +import android.content.Context +import androidx.glance.GlanceId +import androidx.glance.appwidget.GlanceAppWidget +import androidx.glance.appwidget.GlanceAppWidgetReceiver +import androidx.glance.appwidget.SizeMode +import androidx.glance.appwidget.provideContent +import de.jeanlucmakiola.clockula.ui.widget.QuickTimerWidgetRoot +import de.jeanlucmakiola.clockula.widget.di.widgetEntryPoint +import kotlinx.coroutines.flow.first + +/** + * The quick-timer widget: a one-duration timer of its own. Its configured + * duration (written by `QuickTimerConfigActivity`) and the id of the timer it + * last started (written by `QuickTimerReceiver`) are this instance's Glance + * state (`widget/ClockWidgetState.kt`), read reactively inside + * `QuickTimerWidgetRoot` along with the timers themselves — see + * `widget/ClockWidget.kt`'s doc comment for why. + */ +class QuickTimerWidget : GlanceAppWidget() { + + // Content is sized from the live `LocalSize` (`ui/widget/WidgetKit.kt`); + // the default SizeMode.Single would freeze it at the placed size. + override val sizeMode: SizeMode = SizeMode.Exact + + override suspend fun provideGlance(context: Context, id: GlanceId) { + val entryPoint = widgetEntryPoint(context) + val initial = entryPoint.timerRepository().timers().first() + provideContent { QuickTimerWidgetRoot(entryPoint, initial) } + } +} + +class QuickTimerWidgetReceiver : GlanceAppWidgetReceiver() { + override val glanceAppWidget: GlanceAppWidget = QuickTimerWidget() +} diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/widget/StopwatchWidget.kt b/app/src/main/java/de/jeanlucmakiola/clockula/widget/StopwatchWidget.kt new file mode 100644 index 0000000..1731573 --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/clockula/widget/StopwatchWidget.kt @@ -0,0 +1,31 @@ +package de.jeanlucmakiola.clockula.widget + +import android.content.Context +import androidx.glance.GlanceId +import androidx.glance.appwidget.GlanceAppWidget +import androidx.glance.appwidget.GlanceAppWidgetReceiver +import androidx.glance.appwidget.SizeMode +import androidx.glance.appwidget.provideContent +import de.jeanlucmakiola.clockula.ui.widget.StopwatchWidgetRoot +import de.jeanlucmakiola.clockula.widget.di.widgetEntryPoint +import kotlinx.coroutines.flow.first + +/** The stopwatch widget. No `updatePeriodMillis` and no per-second tick of its own — a running readout is the platform chronometer. [WidgetSync] refreshes it on every stopwatch write. */ +class StopwatchWidget : GlanceAppWidget() { + + // Content is sized from the live `LocalSize` (`ui/widget/WidgetKit.kt`); + // the default SizeMode.Single would freeze it at the placed size. + override val sizeMode: SizeMode = SizeMode.Exact + + override suspend fun provideGlance(context: Context, id: GlanceId) { + val entryPoint = widgetEntryPoint(context) + val repository = entryPoint.stopwatchRepository() + val initialRun = repository.run().first() + val initialLaps = repository.laps().first() + provideContent { StopwatchWidgetRoot(entryPoint, initialRun, initialLaps.size) } + } +} + +class StopwatchWidgetReceiver : GlanceAppWidgetReceiver() { + override val glanceAppWidget: GlanceAppWidget = StopwatchWidget() +} diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/widget/TimerWidget.kt b/app/src/main/java/de/jeanlucmakiola/clockula/widget/TimerWidget.kt index b9a5e99..a4d7cb8 100644 --- a/app/src/main/java/de/jeanlucmakiola/clockula/widget/TimerWidget.kt +++ b/app/src/main/java/de/jeanlucmakiola/clockula/widget/TimerWidget.kt @@ -4,24 +4,24 @@ import android.content.Context import androidx.glance.GlanceId import androidx.glance.appwidget.GlanceAppWidget import androidx.glance.appwidget.GlanceAppWidgetReceiver +import androidx.glance.appwidget.SizeMode import androidx.glance.appwidget.provideContent -import de.jeanlucmakiola.clockula.domain.widget.TimerWidgetPresenter -import de.jeanlucmakiola.clockula.ui.widget.TimerWidgetContent +import de.jeanlucmakiola.clockula.ui.widget.TimerWidgetRoot import de.jeanlucmakiola.clockula.widget.di.widgetEntryPoint import kotlinx.coroutines.flow.first /** The running-timer widget. No `updatePeriodMillis`, and no per-second tick of its own — a running row's countdown is the platform chronometer. [WidgetSync] refreshes it on every timer write. */ class TimerWidget : GlanceAppWidget() { + // Content is sized from the live `LocalSize` (`ui/widget/WidgetKit.kt`); + // the default SizeMode.Single would freeze it at the placed size. + override val sizeMode: SizeMode = SizeMode.Exact + override suspend fun provideGlance(context: Context, id: GlanceId) { val entryPoint = widgetEntryPoint(context) - val timers = entryPoint.timerRepository().timers().first() - val model = TimerWidgetPresenter.present( - timers = timers, - elapsedRealtime = entryPoint.elapsedRealtimeClock().elapsedRealtime(), - wallClock = entryPoint.wallClock().now(), - ) - provideContent { TimerWidgetContent(model) } + val repository = entryPoint.timerRepository() + val initial = repository.timers().first() + provideContent { TimerWidgetRoot(entryPoint, initial) } } } diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/widget/WidgetSync.kt b/app/src/main/java/de/jeanlucmakiola/clockula/widget/WidgetSync.kt index da216c2..a557bc1 100644 --- a/app/src/main/java/de/jeanlucmakiola/clockula/widget/WidgetSync.kt +++ b/app/src/main/java/de/jeanlucmakiola/clockula/widget/WidgetSync.kt @@ -3,6 +3,7 @@ package de.jeanlucmakiola.clockula.widget import android.content.Context import dagger.hilt.android.qualifiers.ApplicationContext import de.jeanlucmakiola.clockula.alarm.AlarmEngine +import de.jeanlucmakiola.clockula.data.stopwatch.StopwatchRepository import de.jeanlucmakiola.clockula.data.timers.TimerRepository import de.jeanlucmakiola.clockula.data.worldclocks.WorldClockRepository import androidx.glance.appwidget.updateAll @@ -15,12 +16,16 @@ import javax.inject.Singleton import kotlin.time.Duration.Companion.milliseconds /** - * Keeps the three widgets in step with the app's own state, event-driven — + * Keeps the widgets in step with the app's own state, event-driven — * `updatePeriodMillis = 0` on every widget — by collecting the same read-only * flows the shell already reads (`AlarmEngine.upcoming()`, `TimerRepository. - * timers()`, `WorldClockRepository.worldClocks()`). This covers every engine - * write and every `SystemEventReceiver` path, because the process is alive - * whenever those run — it touches no engine itself, only re-renders. + * timers()`, `StopwatchRepository.run()`, `WorldClockRepository.worldClocks()`). + * This covers every engine write and every `SystemEventReceiver` path, + * because the process is alive whenever those run — it touches no engine + * itself, only re-renders. + * + * The quick-timer widget follows the timers too: it shows the countdown of + * the timer it started (`widget/QuickTimerWidget.kt`). * * Started once, from [de.jeanlucmakiola.clockula.ClockulaApp.onCreate], in the * app's `@ApplicationScope` — never driven by a `Ticker`, so an idle home @@ -31,6 +36,7 @@ class WidgetSync @Inject constructor( @ApplicationContext private val context: Context, private val alarmEngine: AlarmEngine, private val timers: TimerRepository, + private val stopwatch: StopwatchRepository, private val worldClocks: WorldClockRepository, ) { @@ -39,8 +45,12 @@ class WidgetSync @Inject constructor( combine( alarmEngine.upcoming(), timers.timers(), + // A lap leaves the run untouched, so the lap count is watched too. + combine(stopwatch.run(), stopwatch.laps()) { run, laps -> run to laps.size }, worldClocks.worldClocks(), - ) { upcoming, timerRows, clocks -> Triple(upcoming, timerRows, clocks) } + ) { upcoming, timerRows, stopwatchRun, clocks -> + WidgetSyncState(upcoming, timerRows, stopwatchRun, clocks) + } .distinctUntilChanged() // A burst of writes (an import's replace-all, a reorder) collapses // to one redraw rather than one per row. @@ -51,10 +61,16 @@ class WidgetSync @Inject constructor( private suspend fun updateAll() { NextAlarmWidget().updateAll(context) TimerWidget().updateAll(context) + StopwatchWidget().updateAll(context) ClockWidget().updateAll(context) + AnalogClockWidget().updateAll(context) + QuickTimerWidget().updateAll(context) } private companion object { val DEBOUNCE = 150.milliseconds } } + +/** `combine` has no tuple wider than a `Triple`; this is the four-flow one. */ +private data class WidgetSyncState(val a: A, val b: B, val c: C, val d: D) diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/widget/di/WidgetEntryPoint.kt b/app/src/main/java/de/jeanlucmakiola/clockula/widget/di/WidgetEntryPoint.kt index df831b4..58b4fba 100644 --- a/app/src/main/java/de/jeanlucmakiola/clockula/widget/di/WidgetEntryPoint.kt +++ b/app/src/main/java/de/jeanlucmakiola/clockula/widget/di/WidgetEntryPoint.kt @@ -6,6 +6,7 @@ import dagger.hilt.InstallIn import dagger.hilt.android.EntryPointAccessors import dagger.hilt.components.SingletonComponent import de.jeanlucmakiola.clockula.alarm.AlarmEngine +import de.jeanlucmakiola.clockula.data.stopwatch.StopwatchRepository import de.jeanlucmakiola.clockula.data.timers.TimerRepository import de.jeanlucmakiola.clockula.data.worldclocks.WorldClockRepository import de.jeanlucmakiola.clockula.data.zones.ZoneNames @@ -25,6 +26,7 @@ import de.jeanlucmakiola.clockula.domain.time.ZoneProvider interface WidgetEntryPoint { fun alarmEngine(): AlarmEngine fun timerRepository(): TimerRepository + fun stopwatchRepository(): StopwatchRepository fun worldClockRepository(): WorldClockRepository fun zoneNames(): ZoneNames fun wallClock(): WallClock diff --git a/app/src/main/res/drawable/widget_analog_dial_clover.xml b/app/src/main/res/drawable/widget_analog_dial_clover.xml new file mode 100644 index 0000000..f94c451 --- /dev/null +++ b/app/src/main/res/drawable/widget_analog_dial_clover.xml @@ -0,0 +1,19 @@ + + + + + + + diff --git a/app/src/main/res/drawable/widget_analog_dial_cookie.xml b/app/src/main/res/drawable/widget_analog_dial_cookie.xml new file mode 100644 index 0000000..ea22c29 --- /dev/null +++ b/app/src/main/res/drawable/widget_analog_dial_cookie.xml @@ -0,0 +1,15 @@ + + + + + diff --git a/app/src/main/res/drawable/widget_analog_dial_none.xml b/app/src/main/res/drawable/widget_analog_dial_none.xml new file mode 100644 index 0000000..c1b4290 --- /dev/null +++ b/app/src/main/res/drawable/widget_analog_dial_none.xml @@ -0,0 +1,15 @@ + + + + + diff --git a/app/src/main/res/drawable/widget_analog_dial_petal.xml b/app/src/main/res/drawable/widget_analog_dial_petal.xml new file mode 100644 index 0000000..58a25ef --- /dev/null +++ b/app/src/main/res/drawable/widget_analog_dial_petal.xml @@ -0,0 +1,15 @@ + + + + + diff --git a/app/src/main/res/drawable/widget_analog_dial_round.xml b/app/src/main/res/drawable/widget_analog_dial_round.xml new file mode 100644 index 0000000..970ec6c --- /dev/null +++ b/app/src/main/res/drawable/widget_analog_dial_round.xml @@ -0,0 +1,15 @@ + + + + + diff --git a/app/src/main/res/drawable/widget_analog_dial_scallop.xml b/app/src/main/res/drawable/widget_analog_dial_scallop.xml new file mode 100644 index 0000000..4b602a0 --- /dev/null +++ b/app/src/main/res/drawable/widget_analog_dial_scallop.xml @@ -0,0 +1,15 @@ + + + + + diff --git a/app/src/main/res/drawable/widget_analog_dial_square.xml b/app/src/main/res/drawable/widget_analog_dial_square.xml new file mode 100644 index 0000000..12f75c1 --- /dev/null +++ b/app/src/main/res/drawable/widget_analog_dial_square.xml @@ -0,0 +1,15 @@ + + + + + diff --git a/app/src/main/res/drawable/widget_analog_dial_sunny.xml b/app/src/main/res/drawable/widget_analog_dial_sunny.xml new file mode 100644 index 0000000..ef4efa0 --- /dev/null +++ b/app/src/main/res/drawable/widget_analog_dial_sunny.xml @@ -0,0 +1,15 @@ + + + + + diff --git a/app/src/main/res/drawable/widget_analog_hour_bold.xml b/app/src/main/res/drawable/widget_analog_hour_bold.xml new file mode 100644 index 0000000..46aca5c --- /dev/null +++ b/app/src/main/res/drawable/widget_analog_hour_bold.xml @@ -0,0 +1,13 @@ + + + + + diff --git a/app/src/main/res/drawable/widget_analog_hour_hand.xml b/app/src/main/res/drawable/widget_analog_hour_hand.xml new file mode 100644 index 0000000..caf3e7e --- /dev/null +++ b/app/src/main/res/drawable/widget_analog_hour_hand.xml @@ -0,0 +1,22 @@ + + + + + diff --git a/app/src/main/res/drawable/widget_analog_hour_lollipop.xml b/app/src/main/res/drawable/widget_analog_hour_lollipop.xml new file mode 100644 index 0000000..a8f6196 --- /dev/null +++ b/app/src/main/res/drawable/widget_analog_hour_lollipop.xml @@ -0,0 +1,16 @@ + + + + + + diff --git a/app/src/main/res/drawable/widget_analog_hour_slim.xml b/app/src/main/res/drawable/widget_analog_hour_slim.xml new file mode 100644 index 0000000..afc499d --- /dev/null +++ b/app/src/main/res/drawable/widget_analog_hour_slim.xml @@ -0,0 +1,13 @@ + + + + + diff --git a/app/src/main/res/drawable/widget_analog_minute_bold.xml b/app/src/main/res/drawable/widget_analog_minute_bold.xml new file mode 100644 index 0000000..58414c8 --- /dev/null +++ b/app/src/main/res/drawable/widget_analog_minute_bold.xml @@ -0,0 +1,12 @@ + + + + + diff --git a/app/src/main/res/drawable/widget_analog_minute_hand.xml b/app/src/main/res/drawable/widget_analog_minute_hand.xml new file mode 100644 index 0000000..c1c5571 --- /dev/null +++ b/app/src/main/res/drawable/widget_analog_minute_hand.xml @@ -0,0 +1,17 @@ + + + + + + diff --git a/app/src/main/res/drawable/widget_analog_minute_lollipop.xml b/app/src/main/res/drawable/widget_analog_minute_lollipop.xml new file mode 100644 index 0000000..f7ebd65 --- /dev/null +++ b/app/src/main/res/drawable/widget_analog_minute_lollipop.xml @@ -0,0 +1,19 @@ + + + + + + + diff --git a/app/src/main/res/drawable/widget_analog_minute_slim.xml b/app/src/main/res/drawable/widget_analog_minute_slim.xml new file mode 100644 index 0000000..e6c3726 --- /dev/null +++ b/app/src/main/res/drawable/widget_analog_minute_slim.xml @@ -0,0 +1,21 @@ + + + + + + + diff --git a/app/src/main/res/drawable/widget_analog_second_dot.xml b/app/src/main/res/drawable/widget_analog_second_dot.xml new file mode 100644 index 0000000..95e7214 --- /dev/null +++ b/app/src/main/res/drawable/widget_analog_second_dot.xml @@ -0,0 +1,14 @@ + + + + + diff --git a/app/src/main/res/drawable/widget_analog_second_dot_bold.xml b/app/src/main/res/drawable/widget_analog_second_dot_bold.xml new file mode 100644 index 0000000..1a20e38 --- /dev/null +++ b/app/src/main/res/drawable/widget_analog_second_dot_bold.xml @@ -0,0 +1,13 @@ + + + + + diff --git a/app/src/main/res/drawable/widget_analog_second_dot_dated.xml b/app/src/main/res/drawable/widget_analog_second_dot_dated.xml new file mode 100644 index 0000000..18466a9 --- /dev/null +++ b/app/src/main/res/drawable/widget_analog_second_dot_dated.xml @@ -0,0 +1,15 @@ + + + + + diff --git a/app/src/main/res/drawable/widget_analog_second_dot_petal.xml b/app/src/main/res/drawable/widget_analog_second_dot_petal.xml new file mode 100644 index 0000000..9f9108b --- /dev/null +++ b/app/src/main/res/drawable/widget_analog_second_dot_petal.xml @@ -0,0 +1,14 @@ + + + + + diff --git a/app/src/main/res/drawable/widget_analog_second_dot_scallop.xml b/app/src/main/res/drawable/widget_analog_second_dot_scallop.xml new file mode 100644 index 0000000..ddcac6d --- /dev/null +++ b/app/src/main/res/drawable/widget_analog_second_dot_scallop.xml @@ -0,0 +1,15 @@ + + + + + diff --git a/app/src/main/res/drawable/widget_badge_pill_background.xml b/app/src/main/res/drawable/widget_badge_pill_background.xml new file mode 100644 index 0000000..5d90345 --- /dev/null +++ b/app/src/main/res/drawable/widget_badge_pill_background.xml @@ -0,0 +1,8 @@ + + + + + + diff --git a/app/src/main/res/drawable/widget_bg_card.xml b/app/src/main/res/drawable/widget_bg_card.xml new file mode 100644 index 0000000..f32dbe3 --- /dev/null +++ b/app/src/main/res/drawable/widget_bg_card.xml @@ -0,0 +1,9 @@ + + + + + + diff --git a/app/src/main/res/drawable/widget_bg_circle.xml b/app/src/main/res/drawable/widget_bg_circle.xml new file mode 100644 index 0000000..2549282 --- /dev/null +++ b/app/src/main/res/drawable/widget_bg_circle.xml @@ -0,0 +1,6 @@ + + + + + diff --git a/app/src/main/res/drawable/widget_bg_pill.xml b/app/src/main/res/drawable/widget_bg_pill.xml new file mode 100644 index 0000000..3c915a0 --- /dev/null +++ b/app/src/main/res/drawable/widget_bg_pill.xml @@ -0,0 +1,7 @@ + + + + + + diff --git a/app/src/main/res/drawable/widget_digital_card_primary.xml b/app/src/main/res/drawable/widget_digital_card_primary.xml new file mode 100644 index 0000000..5ef232d --- /dev/null +++ b/app/src/main/res/drawable/widget_digital_card_primary.xml @@ -0,0 +1,9 @@ + + + + + + diff --git a/app/src/main/res/drawable/widget_digital_card_surface.xml b/app/src/main/res/drawable/widget_digital_card_surface.xml new file mode 100644 index 0000000..5cdc1a8 --- /dev/null +++ b/app/src/main/res/drawable/widget_digital_card_surface.xml @@ -0,0 +1,9 @@ + + + + + + diff --git a/app/src/main/res/drawable/widget_digital_pill_primary.xml b/app/src/main/res/drawable/widget_digital_pill_primary.xml new file mode 100644 index 0000000..610ad4e --- /dev/null +++ b/app/src/main/res/drawable/widget_digital_pill_primary.xml @@ -0,0 +1,9 @@ + + + + + + diff --git a/app/src/main/res/drawable/widget_digital_pill_tertiary.xml b/app/src/main/res/drawable/widget_digital_pill_tertiary.xml new file mode 100644 index 0000000..4b755a8 --- /dev/null +++ b/app/src/main/res/drawable/widget_digital_pill_tertiary.xml @@ -0,0 +1,9 @@ + + + + + + diff --git a/app/src/main/res/drawable/widget_digital_shape_square.xml b/app/src/main/res/drawable/widget_digital_shape_square.xml new file mode 100644 index 0000000..0b5bfa9 --- /dev/null +++ b/app/src/main/res/drawable/widget_digital_shape_square.xml @@ -0,0 +1,13 @@ + + + + + diff --git a/app/src/main/res/drawable/widget_ic_add.xml b/app/src/main/res/drawable/widget_ic_add.xml new file mode 100644 index 0000000..34ca22a --- /dev/null +++ b/app/src/main/res/drawable/widget_ic_add.xml @@ -0,0 +1,11 @@ + + + + + diff --git a/app/src/main/res/drawable/widget_ic_alarm.xml b/app/src/main/res/drawable/widget_ic_alarm.xml new file mode 100644 index 0000000..04f7d3d --- /dev/null +++ b/app/src/main/res/drawable/widget_ic_alarm.xml @@ -0,0 +1,11 @@ + + + + + diff --git a/app/src/main/res/drawable/widget_ic_alarm_off.xml b/app/src/main/res/drawable/widget_ic_alarm_off.xml new file mode 100644 index 0000000..9ad38fb --- /dev/null +++ b/app/src/main/res/drawable/widget_ic_alarm_off.xml @@ -0,0 +1,11 @@ + + + + + diff --git a/app/src/main/res/drawable/widget_ic_flag.xml b/app/src/main/res/drawable/widget_ic_flag.xml new file mode 100644 index 0000000..b1db2f4 --- /dev/null +++ b/app/src/main/res/drawable/widget_ic_flag.xml @@ -0,0 +1,11 @@ + + + + + diff --git a/app/src/main/res/drawable/widget_ic_pause.xml b/app/src/main/res/drawable/widget_ic_pause.xml new file mode 100644 index 0000000..8cdfe4c --- /dev/null +++ b/app/src/main/res/drawable/widget_ic_pause.xml @@ -0,0 +1,11 @@ + + + + + diff --git a/app/src/main/res/drawable/widget_ic_play.xml b/app/src/main/res/drawable/widget_ic_play.xml new file mode 100644 index 0000000..663467b --- /dev/null +++ b/app/src/main/res/drawable/widget_ic_play.xml @@ -0,0 +1,11 @@ + + + + + diff --git a/app/src/main/res/drawable/widget_ic_replay.xml b/app/src/main/res/drawable/widget_ic_replay.xml new file mode 100644 index 0000000..f9d61cd --- /dev/null +++ b/app/src/main/res/drawable/widget_ic_replay.xml @@ -0,0 +1,11 @@ + + + + + diff --git a/app/src/main/res/drawable/widget_ic_stop.xml b/app/src/main/res/drawable/widget_ic_stop.xml new file mode 100644 index 0000000..9167dcd --- /dev/null +++ b/app/src/main/res/drawable/widget_ic_stop.xml @@ -0,0 +1,11 @@ + + + + + diff --git a/app/src/main/res/drawable/widget_shape_cookie.xml b/app/src/main/res/drawable/widget_shape_cookie.xml new file mode 100644 index 0000000..b2f6b38 --- /dev/null +++ b/app/src/main/res/drawable/widget_shape_cookie.xml @@ -0,0 +1,12 @@ + + + + + diff --git a/app/src/main/res/drawable/widget_shape_scallop.xml b/app/src/main/res/drawable/widget_shape_scallop.xml new file mode 100644 index 0000000..b6aa477 --- /dev/null +++ b/app/src/main/res/drawable/widget_shape_scallop.xml @@ -0,0 +1,12 @@ + + + + + diff --git a/app/src/main/res/drawable/widget_shape_square.xml b/app/src/main/res/drawable/widget_shape_square.xml new file mode 100644 index 0000000..517c732 --- /dev/null +++ b/app/src/main/res/drawable/widget_shape_square.xml @@ -0,0 +1,12 @@ + + + + + diff --git a/app/src/main/res/layout/widget_analog_badge.xml b/app/src/main/res/layout/widget_analog_badge.xml new file mode 100644 index 0000000..8831b93 --- /dev/null +++ b/app/src/main/res/layout/widget_analog_badge.xml @@ -0,0 +1,66 @@ + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/widget_analog_numeral.xml b/app/src/main/res/layout/widget_analog_numeral.xml new file mode 100644 index 0000000..223293d --- /dev/null +++ b/app/src/main/res/layout/widget_analog_numeral.xml @@ -0,0 +1,51 @@ + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/widget_analog_plain.xml b/app/src/main/res/layout/widget_analog_plain.xml new file mode 100644 index 0000000..45d4202 --- /dev/null +++ b/app/src/main/res/layout/widget_analog_plain.xml @@ -0,0 +1,42 @@ + + + + + + + + + + + + diff --git a/app/src/main/res/layout/widget_chronometer.xml b/app/src/main/res/layout/widget_chronometer.xml index afdd1cf..e9730c5 100644 --- a/app/src/main/res/layout/widget_chronometer.xml +++ b/app/src/main/res/layout/widget_chronometer.xml @@ -1,13 +1,17 @@ diff --git a/app/src/main/res/layout/widget_chronometer_on_primary.xml b/app/src/main/res/layout/widget_chronometer_on_primary.xml new file mode 100644 index 0000000..d9eb85b --- /dev/null +++ b/app/src/main/res/layout/widget_chronometer_on_primary.xml @@ -0,0 +1,15 @@ + + + diff --git a/app/src/main/res/layout/widget_digital_bold.xml b/app/src/main/res/layout/widget_digital_bold.xml new file mode 100644 index 0000000..b042c0c --- /dev/null +++ b/app/src/main/res/layout/widget_digital_bold.xml @@ -0,0 +1,42 @@ + + + + + + + + + + + + diff --git a/app/src/main/res/layout/widget_digital_city.xml b/app/src/main/res/layout/widget_digital_city.xml new file mode 100644 index 0000000..a4f86d8 --- /dev/null +++ b/app/src/main/res/layout/widget_digital_city.xml @@ -0,0 +1,32 @@ + + + + + + + + + diff --git a/app/src/main/res/layout/widget_digital_city_on_primary.xml b/app/src/main/res/layout/widget_digital_city_on_primary.xml new file mode 100644 index 0000000..60aa659 --- /dev/null +++ b/app/src/main/res/layout/widget_digital_city_on_primary.xml @@ -0,0 +1,32 @@ + + + + + + + + + diff --git a/app/src/main/res/layout/widget_digital_classic.xml b/app/src/main/res/layout/widget_digital_classic.xml new file mode 100644 index 0000000..47632d9 --- /dev/null +++ b/app/src/main/res/layout/widget_digital_classic.xml @@ -0,0 +1,42 @@ + + + + + + + + + + + + diff --git a/app/src/main/res/layout/widget_digital_pill.xml b/app/src/main/res/layout/widget_digital_pill.xml new file mode 100644 index 0000000..9509308 --- /dev/null +++ b/app/src/main/res/layout/widget_digital_pill.xml @@ -0,0 +1,44 @@ + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/widget_digital_stacked.xml b/app/src/main/res/layout/widget_digital_stacked.xml new file mode 100644 index 0000000..88bb9ae --- /dev/null +++ b/app/src/main/res/layout/widget_digital_stacked.xml @@ -0,0 +1,55 @@ + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/widget_digital_thin.xml b/app/src/main/res/layout/widget_digital_thin.xml new file mode 100644 index 0000000..82ee4c3 --- /dev/null +++ b/app/src/main/res/layout/widget_digital_thin.xml @@ -0,0 +1,42 @@ + + + + + + + + + + + + diff --git a/app/src/main/res/layout/widget_readout.xml b/app/src/main/res/layout/widget_readout.xml new file mode 100644 index 0000000..d5f9866 --- /dev/null +++ b/app/src/main/res/layout/widget_readout.xml @@ -0,0 +1,17 @@ + + + diff --git a/app/src/main/res/layout/widget_text_clock.xml b/app/src/main/res/layout/widget_text_clock.xml deleted file mode 100644 index 1869452..0000000 --- a/app/src/main/res/layout/widget_text_clock.xml +++ /dev/null @@ -1,13 +0,0 @@ - - - diff --git a/app/src/main/res/values-night-v31/colors.xml b/app/src/main/res/values-night-v31/colors.xml new file mode 100644 index 0000000..a524639 --- /dev/null +++ b/app/src/main/res/values-night-v31/colors.xml @@ -0,0 +1,24 @@ + + + @android:color/system_accent2_800 + @android:color/system_accent1_800 + @android:color/system_accent3_800 + @android:color/system_accent1_100 + @android:color/system_accent1_300 + @android:color/system_accent1_200 + @android:color/system_accent3_300 + @android:color/system_neutral1_50 + @android:color/system_accent3_300 + @android:color/system_accent3_900 + + + @android:color/system_accent2_800 + @android:color/system_neutral1_50 + @android:color/system_neutral2_200 + @android:color/system_accent1_200 + @android:color/system_accent1_700 + @android:color/system_accent1_100 + @android:color/system_accent3_200 + @android:color/system_accent3_700 + @android:color/system_accent3_100 + diff --git a/app/src/main/res/values-night/colors.xml b/app/src/main/res/values-night/colors.xml new file mode 100644 index 0000000..203ba07 --- /dev/null +++ b/app/src/main/res/values-night/colors.xml @@ -0,0 +1,26 @@ + + + #FF2B2F26 + #FF3E4C32 + #FF334B47 + #FFE4E9E0 + #FFB4C7A4 + #FFC9D9B9 + #FFA5CEC7 + #FFE3E3DC + #FFB4C7A4 + #FF22261C + + + #FF2B2F26 + #FFE4E9E0 + #FFC4C8BA + #FFCEE2BA + #FF4A583B + #FFE8F6D9 + #FFA5CEC7 + #FF244D48 + #FFC1ECE4 + diff --git a/app/src/main/res/values-v31/colors.xml b/app/src/main/res/values-v31/colors.xml new file mode 100644 index 0000000..dbb32fa --- /dev/null +++ b/app/src/main/res/values-v31/colors.xml @@ -0,0 +1,28 @@ + + + @android:color/system_accent2_50 + @android:color/system_accent1_100 + @android:color/system_accent3_100 + @android:color/system_accent1_700 + @android:color/system_accent1_400 + @android:color/system_accent1_600 + @android:color/system_accent3_500 + @android:color/system_neutral1_900 + @android:color/system_accent3_200 + @android:color/system_accent3_900 + + + @android:color/system_accent2_50 + @android:color/system_neutral1_900 + @android:color/system_neutral2_700 + @android:color/system_accent1_600 + @android:color/system_accent1_100 + @android:color/system_accent1_900 + @android:color/system_accent3_600 + @android:color/system_accent3_100 + @android:color/system_accent3_900 + diff --git a/app/src/main/res/values-v31/dimens.xml b/app/src/main/res/values-v31/dimens.xml new file mode 100644 index 0000000..ea9ae34 --- /dev/null +++ b/app/src/main/res/values-v31/dimens.xml @@ -0,0 +1,4 @@ + + + @android:dimen/system_app_widget_background_radius + diff --git a/app/src/main/res/values/colors.xml b/app/src/main/res/values/colors.xml index 080f756..ba4d168 100644 --- a/app/src/main/res/values/colors.xml +++ b/app/src/main/res/values/colors.xml @@ -4,4 +4,37 @@ #FF6B7A5C #FF6B7A5C + + + #FFE8EAE1 + #FFD7E8C6 + #FFCCE8E3 + #FF44483E + #FF6B7A5C + #FF4E5C40 + #FF3D6660 + #FF1A1C18 + #FF6B7A5C + #FFFFFFFF + + + #FFE8EAE1 + #FF1C1F1A + #FF44483E + #FF5C6A4E + #FFD7E8C6 + #FF1D2A10 + #FF3D6660 + #FFCCE8E3 + #FF05201C diff --git a/app/src/main/res/values/dimens.xml b/app/src/main/res/values/dimens.xml new file mode 100644 index 0000000..a031b9e --- /dev/null +++ b/app/src/main/res/values/dimens.xml @@ -0,0 +1,4 @@ + + + 20dp + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 8c8db25..d0e3de9 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -348,6 +348,40 @@ Today Timer New timer - Time\'s up + Done Clock + + + Digital clock + Style + Classic + Bold + Thin + Pill + Stacked + Show date + World clocks + Up to three cities from your World clock tab + + + Analog clock + Analog clock + Round + Numeral + Day + Cookie + Clover + Petal + Sunny + Square + Scallop + + + Stopwatch + Laps: %1$d + + + Quick timer + Quick timer + Start %1$s diff --git a/app/src/main/res/values/themes.xml b/app/src/main/res/values/themes.xml index 5aa70ef..9230f70 100644 --- a/app/src/main/res/values/themes.xml +++ b/app/src/main/res/values/themes.xml @@ -8,4 +8,16 @@ diff --git a/app/src/main/res/values/widget_shapes.xml b/app/src/main/res/values/widget_shapes.xml new file mode 100644 index 0000000..914d782 --- /dev/null +++ b/app/src/main/res/values/widget_shapes.xml @@ -0,0 +1,20 @@ + + + + + M50.00,2.49 C52.46,2.49 54.92,3.48 56.72,5.46 C59.11,8.09 62.82,9.08 66.20,8.00 C71.30,6.38 76.70,9.49 77.84,14.72 C78.60,18.19 81.31,20.90 84.78,21.66 C90.01,22.81 93.13,28.20 91.50,33.30 C90.42,36.69 91.42,40.39 94.04,42.78 C98.00,46.39 98.00,52.62 94.04,56.23 C91.42,58.62 90.42,62.32 91.50,65.71 C93.13,70.81 90.01,76.20 84.78,77.35 C81.31,78.11 78.60,80.82 77.84,84.29 C76.70,89.52 71.30,92.63 66.20,91.01 C62.82,89.93 59.11,90.92 56.72,93.55 C53.11,97.51 46.89,97.51 43.28,93.55 C40.89,90.92 37.18,89.93 33.80,91.01 C28.70,92.63 23.30,89.52 22.16,84.29 C21.40,80.82 18.69,78.11 15.22,77.35 C9.99,76.20 6.87,70.81 8.50,65.71 C9.58,62.32 8.58,58.62 5.96,56.23 C2.00,52.62 2.00,46.39 5.96,42.78 C8.58,40.39 9.58,36.69 8.50,33.30 C6.87,28.20 9.99,22.81 15.22,21.66 C18.69,20.90 21.40,18.19 22.16,14.72 C23.30,9.49 28.70,6.38 33.80,8.00 C37.18,9.08 40.89,8.09 43.28,5.46 C45.08,3.48 47.54,2.49 50.00,2.49 Z + + M97.70,50.00 C97.70,51.72 97.11,53.45 95.92,54.84 C93.49,57.69 91.06,60.54 88.63,63.39 C87.61,64.59 87.00,66.07 86.87,67.64 C86.58,71.37 86.28,75.10 85.98,78.84 C85.69,82.49 82.79,85.39 79.14,85.68 C75.40,85.98 71.67,86.28 67.94,86.58 C66.37,86.70 64.88,87.32 63.69,88.34 C60.84,90.76 57.99,93.19 55.14,95.62 C52.35,98.00 48.25,98.00 45.46,95.62 C42.61,93.19 39.76,90.76 36.91,88.34 C35.71,87.32 34.22,86.70 32.66,86.58 C28.93,86.28 25.19,85.98 21.46,85.68 C17.81,85.39 14.91,82.49 14.61,78.84 C14.32,75.10 14.02,71.37 13.72,67.64 C13.60,66.07 12.98,64.59 11.96,63.39 C9.53,60.54 7.10,57.69 4.67,54.84 C2.30,52.05 2.30,47.95 4.67,45.16 C7.10,42.31 9.53,39.46 11.96,36.61 C12.98,35.41 13.60,33.93 13.72,32.36 C14.02,28.63 14.32,24.90 14.61,21.16 C14.91,17.51 17.81,14.61 21.46,14.32 C25.19,14.02 28.93,13.72 32.66,13.42 C34.22,13.30 35.71,12.68 36.91,11.66 C39.76,9.24 42.61,6.81 45.46,4.38 C48.25,2.00 52.35,2.00 55.14,4.38 C57.99,6.81 60.84,9.24 63.69,11.66 C64.88,12.68 66.37,13.30 67.94,13.42 C71.67,13.72 75.40,14.02 79.14,14.32 C82.79,14.61 85.69,17.51 85.98,21.16 C86.28,24.90 86.58,28.63 86.87,32.36 C87.00,33.93 87.61,35.41 88.63,36.61 C91.06,39.46 93.49,42.31 95.92,45.16 C97.11,46.55 97.70,48.28 97.70,50.00 Z + + M70.79,85.80 C69.17,86.73 67.66,87.86 66.30,89.17 C66.26,89.22 66.21,89.26 66.16,89.31 C57.15,98.00 42.87,97.99 33.87,89.28 C31.16,86.66 27.85,84.75 24.23,83.70 C24.17,83.69 24.10,83.67 24.04,83.65 C12.00,80.19 4.87,67.82 7.92,55.67 C8.83,52.01 8.83,48.19 7.93,44.53 C7.91,44.47 7.89,44.40 7.88,44.34 C4.86,32.19 12.01,19.83 24.05,16.39 C27.67,15.35 30.98,13.44 33.70,10.83 C33.74,10.78 33.79,10.74 33.84,10.69 C42.85,2.00 57.13,2.01 66.13,10.72 C68.84,13.34 72.15,15.25 75.77,16.30 C75.83,16.31 75.90,16.33 75.96,16.35 C88.00,19.81 95.13,32.18 92.08,44.33 C91.17,47.99 91.17,51.81 92.07,55.47 C92.09,55.53 92.11,55.60 92.12,55.66 C95.14,67.81 87.99,80.17 75.95,83.61 C74.14,84.13 72.41,84.86 70.79,85.80 Z + + M89.56,89.56 C84.35,94.78 77.15,98.00 69.20,98.00 C56.40,98.00 43.60,98.00 30.80,98.00 C14.89,98.00 2.00,85.11 2.00,69.20 C2.00,56.40 2.00,43.60 2.00,30.80 C2.00,14.89 14.89,2.00 30.80,2.00 C43.60,2.00 56.40,2.00 69.20,2.00 C85.11,2.00 98.00,14.89 98.00,30.80 C98.00,43.60 98.00,56.40 98.00,69.20 C98.00,77.15 94.78,84.35 89.56,89.56 Z + + M50.00,8.84 C50.70,8.47 51.39,8.10 52.09,7.73 C62.69,2.11 75.76,7.87 78.74,19.49 C78.86,19.96 78.98,20.43 79.10,20.90 C79.86,21.13 80.61,21.36 81.37,21.59 C92.84,25.11 98.00,38.42 91.89,48.75 C91.65,49.17 91.40,49.58 91.16,50.00 C91.53,50.70 91.90,51.39 92.27,52.09 C97.89,62.69 92.13,75.76 80.51,78.74 C80.04,78.86 79.57,78.98 79.10,79.10 C78.87,79.86 78.64,80.61 78.41,81.37 C74.89,92.84 61.58,98.00 51.25,91.89 C50.83,91.65 50.42,91.40 50.00,91.16 C49.30,91.53 48.61,91.90 47.91,92.27 C37.31,97.89 24.24,92.13 21.26,80.51 C21.14,80.04 21.02,79.57 20.90,79.10 C20.14,78.87 19.39,78.64 18.63,78.41 C7.16,74.89 2.00,61.58 8.11,51.25 C8.35,50.83 8.60,50.42 8.84,50.00 C8.47,49.30 8.10,48.61 7.73,47.91 C2.11,37.31 7.87,24.24 19.49,21.26 C19.96,21.14 20.43,21.02 20.90,20.90 C21.13,20.14 21.36,19.39 21.59,18.63 C25.11,7.16 38.42,2.00 48.75,8.11 C49.17,8.35 49.58,8.60 50.00,8.84 Z + + M50.00,11.42 C50.46,11.06 50.93,10.71 51.39,10.35 C62.25,2.00 77.62,3.00 87.31,12.69 C97.00,22.38 98.00,37.75 89.65,48.61 C89.29,49.07 88.94,49.54 88.58,50.00 C88.94,50.46 89.29,50.93 89.65,51.39 C98.00,62.25 97.00,77.62 87.31,87.31 C77.62,97.00 62.25,98.00 51.39,89.65 C50.93,89.29 50.46,88.94 50.00,88.58 C49.54,88.94 49.07,89.29 48.61,89.65 C37.75,98.00 22.38,97.00 12.69,87.31 C3.00,77.62 2.00,62.25 10.35,51.39 C10.71,50.93 11.06,50.46 11.42,50.00 C11.06,49.54 10.71,49.07 10.35,48.61 C2.00,37.75 3.00,22.38 12.69,12.69 C22.38,3.00 37.75,2.00 48.61,10.35 C49.07,10.71 49.54,11.06 50.00,11.42 Z + diff --git a/app/src/main/res/xml/widget_analog_clock_info.xml b/app/src/main/res/xml/widget_analog_clock_info.xml new file mode 100644 index 0000000..ebbf703 --- /dev/null +++ b/app/src/main/res/xml/widget_analog_clock_info.xml @@ -0,0 +1,15 @@ + + diff --git a/app/src/main/res/xml/widget_clock_info.xml b/app/src/main/res/xml/widget_clock_info.xml index c0bbdd6..1e4f328 100644 --- a/app/src/main/res/xml/widget_clock_info.xml +++ b/app/src/main/res/xml/widget_clock_info.xml @@ -9,5 +9,7 @@ android:updatePeriodMillis="0" android:resizeMode="horizontal|vertical" android:widgetCategory="home_screen" - android:previewImage="@mipmap/ic_launcher" - android:description="@string/widget_clock_description" /> + android:description="@string/widget_clock_description" + android:configure="de.jeanlucmakiola.clockula.ui.widget.config.ClockWidgetConfigActivity" + android:widgetFeatures="reconfigurable|configuration_optional" + android:previewLayout="@layout/widget_digital_classic" /> diff --git a/app/src/main/res/xml/widget_quick_timer_info.xml b/app/src/main/res/xml/widget_quick_timer_info.xml new file mode 100644 index 0000000..65e8970 --- /dev/null +++ b/app/src/main/res/xml/widget_quick_timer_info.xml @@ -0,0 +1,15 @@ + + diff --git a/app/src/main/res/xml/widget_stopwatch_info.xml b/app/src/main/res/xml/widget_stopwatch_info.xml new file mode 100644 index 0000000..13f2f68 --- /dev/null +++ b/app/src/main/res/xml/widget_stopwatch_info.xml @@ -0,0 +1,13 @@ + + diff --git a/app/src/test/java/de/jeanlucmakiola/clockula/ManifestRulesTest.kt b/app/src/test/java/de/jeanlucmakiola/clockula/ManifestRulesTest.kt index 9ce0ca7..d7905a9 100644 --- a/app/src/test/java/de/jeanlucmakiola/clockula/ManifestRulesTest.kt +++ b/app/src/test/java/de/jeanlucmakiola/clockula/ManifestRulesTest.kt @@ -31,7 +31,7 @@ class ManifestRulesTest { } @Test - fun `exactly the launcher, the door and the three widget receivers are exported`() { + fun `exactly the launcher, the door, the three widget configurators and the six widget receivers are exported`() { val exported = Regex("<(activity|service|receiver|provider)\\b[\\s\\S]*?(?:/>|)") .findAll(text) .map { it.value } @@ -39,20 +39,53 @@ class ManifestRulesTest { .map { Regex("android:name=\"([^\"]+)\"").find(it)!!.groupValues[1] } .toList() - // M10 adds exactly three: a GlanceAppWidgetReceiver must be exported - // for the platform to pick it as a widget provider at all, and - // `the three widget receivers answer only APPWIDGET_UPDATE` pins that - // each one is guarded to that one platform-delivered broadcast and - // nothing wider. + // A GlanceAppWidgetReceiver must be exported for the platform to pick + // it as a widget provider at all, and `the six widget receivers + // answer only APPWIDGET_UPDATE` pins that each one is guarded to + // that one platform-delivered broadcast and nothing wider. M11 adds + // three widget config activities: exported for the same reason — + // only the launcher can start an `APPWIDGET_CONFIGURE` activity + // cross-package — and guarded to that one action by `the three + // widget configurators answer only APPWIDGET_CONFIGURE` below. assertThat(exported).containsExactly( ".MainActivity", ".interop.AlarmClockActivity", + ".ui.widget.config.ClockWidgetConfigActivity", + ".ui.widget.config.AnalogClockConfigActivity", + ".ui.widget.config.QuickTimerConfigActivity", ".widget.NextAlarmWidgetReceiver", ".widget.TimerWidgetReceiver", ".widget.ClockWidgetReceiver", + ".widget.AnalogClockWidgetReceiver", + ".widget.StopwatchWidgetReceiver", + ".widget.QuickTimerWidgetReceiver", ) } + /** + * M11: the mirror of `the six widget receivers answer only + * APPWIDGET_UPDATE` for the three widget config activities — being + * exported cannot be widened into a second, wider surface by accident. + */ + @Test + fun `the three widget configurators answer only APPWIDGET_CONFIGURE`() { + val configurators = listOf( + ".ui.widget.config.ClockWidgetConfigActivity", + ".ui.widget.config.AnalogClockConfigActivity", + ".ui.widget.config.QuickTimerConfigActivity", + ) + for (name in configurators) { + val block = activityBlock(name) + + val actions = Regex("|)") .findAll(text) @@ -113,17 +149,20 @@ class ManifestRulesTest { } /** - * M10: a `GlanceAppWidgetReceiver` must be exported for the platform to - * offer it as a widget provider, but that is the *only* door it may open — - * each one answers `APPWIDGET_UPDATE` and nothing else, so being exported + * A `GlanceAppWidgetReceiver` must be exported for the platform to offer + * it as a widget provider, but that is the *only* door it may open — each + * one answers `APPWIDGET_UPDATE` and nothing else, so being exported * cannot be widened into a second, wider surface by accident. */ @Test - fun `the three widget receivers answer only APPWIDGET_UPDATE`() { + fun `the six widget receivers answer only APPWIDGET_UPDATE`() { val widgetReceivers = listOf( ".widget.NextAlarmWidgetReceiver", ".widget.TimerWidgetReceiver", ".widget.ClockWidgetReceiver", + ".widget.AnalogClockWidgetReceiver", + ".widget.StopwatchWidgetReceiver", + ".widget.QuickTimerWidgetReceiver", ) for (name in widgetReceivers) { val block = Regex("|)") diff --git a/app/src/test/java/de/jeanlucmakiola/clockula/domain/widget/ClockWidgetSettingsTest.kt b/app/src/test/java/de/jeanlucmakiola/clockula/domain/widget/ClockWidgetSettingsTest.kt new file mode 100644 index 0000000..3279aec --- /dev/null +++ b/app/src/test/java/de/jeanlucmakiola/clockula/domain/widget/ClockWidgetSettingsTest.kt @@ -0,0 +1,65 @@ +package de.jeanlucmakiola.clockula.domain.widget + +import com.google.common.truth.Truth.assertThat +import org.junit.jupiter.api.Test + +class ClockWidgetSettingsTest { + + @Test + fun `null parses to Regular`() { + assertThat(parseDigitalClockStyle(null)).isEqualTo(DigitalClockStyle.REGULAR) + } + + @Test + fun `an unknown name parses to Regular`() { + assertThat(parseDigitalClockStyle("Nonexistent")).isEqualTo(DigitalClockStyle.REGULAR) + } + + @Test + fun `each digital style's own name round-trips`() { + DigitalClockStyle.entries.forEach { style -> + assertThat(parseDigitalClockStyle(style.name)).isEqualTo(style) + } + } + + @Test + fun `digital settings default to Regular with the date on and world clocks off`() { + val settings = DigitalClockSettings() + + assertThat(settings.style).isEqualTo(DigitalClockStyle.REGULAR) + assertThat(settings.showDate).isTrue() + assertThat(settings.showWorldClocks).isFalse() + } + + @Test + fun `null parses to Round`() { + assertThat(parseAnalogClockStyle(null)).isEqualTo(AnalogClockStyle.ROUND) + } + + @Test + fun `an unknown analog name parses to Round`() { + assertThat(parseAnalogClockStyle("Nonexistent")).isEqualTo(AnalogClockStyle.ROUND) + } + + @Test + fun `each analog style's own name round-trips`() { + AnalogClockStyle.entries.forEach { style -> + assertThat(parseAnalogClockStyle(style.name)).isEqualTo(style) + } + } + + @Test + fun `null quick-timer duration parses to the default`() { + assertThat(parseQuickTimerDuration(null)).isEqualTo(QuickTimerWidgetSettings.DEFAULT) + } + + @Test + fun `an out-of-range quick-timer duration parses to the default`() { + assertThat(parseQuickTimerDuration(0L)).isEqualTo(QuickTimerWidgetSettings.DEFAULT) + } + + @Test + fun `an in-range quick-timer duration round-trips`() { + assertThat(parseQuickTimerDuration(600_000L).inWholeMilliseconds).isEqualTo(600_000L) + } +} diff --git a/app/src/test/java/de/jeanlucmakiola/clockula/domain/widget/QuickTimerWidgetModelTest.kt b/app/src/test/java/de/jeanlucmakiola/clockula/domain/widget/QuickTimerWidgetModelTest.kt new file mode 100644 index 0000000..a9c686c --- /dev/null +++ b/app/src/test/java/de/jeanlucmakiola/clockula/domain/widget/QuickTimerWidgetModelTest.kt @@ -0,0 +1,47 @@ +package de.jeanlucmakiola.clockula.domain.widget + +import com.google.common.truth.Truth.assertThat +import de.jeanlucmakiola.clockula.domain.timer.TimerMode +import de.jeanlucmakiola.clockula.testing.T0 +import de.jeanlucmakiola.clockula.testing.expiredTimer +import de.jeanlucmakiola.clockula.testing.idleTimer +import de.jeanlucmakiola.clockula.testing.pausedTimer +import de.jeanlucmakiola.clockula.testing.runningTimerWith +import org.junit.jupiter.api.Test +import kotlin.time.Duration +import kotlin.time.Duration.Companion.minutes + +class QuickTimerWidgetModelTest { + + @Test + fun `no started timer is at rest`() { + val model = QuickTimerWidgetPresenter.present(5.minutes, null, listOf(runningTimerWith(id = 1, left = 1.minutes)), Duration.ZERO, T0) + + assertThat(model.active).isNull() + assertThat(model.duration).isEqualTo(5.minutes) + } + + @Test + fun `its own running timer is active`() { + val timers = listOf(runningTimerWith(id = 1, left = 1.minutes), runningTimerWith(id = 2, left = 3.minutes)) + + val model = QuickTimerWidgetPresenter.present(5.minutes, 2L, timers, Duration.ZERO, T0) + + assertThat(model.active?.id).isEqualTo(2L) + assertThat(model.active?.mode).isEqualTo(TimerMode.RUNNING) + } + + @Test + fun `paused and expired stay active`() { + assertThat(QuickTimerWidgetPresenter.present(5.minutes, 1L, listOf(pausedTimer(id = 1)), Duration.ZERO, T0).active?.mode) + .isEqualTo(TimerMode.PAUSED) + assertThat(QuickTimerWidgetPresenter.present(5.minutes, 1L, listOf(expiredTimer(id = 1)), Duration.ZERO, T0).active?.mode) + .isEqualTo(TimerMode.EXPIRED) + } + + @Test + fun `a deleted or idle timer is back at rest`() { + assertThat(QuickTimerWidgetPresenter.present(5.minutes, 1L, emptyList(), Duration.ZERO, T0).active).isNull() + assertThat(QuickTimerWidgetPresenter.present(5.minutes, 1L, listOf(idleTimer(id = 1)), Duration.ZERO, T0).active).isNull() + } +} diff --git a/app/src/test/java/de/jeanlucmakiola/clockula/domain/widget/StopwatchWidgetModelTest.kt b/app/src/test/java/de/jeanlucmakiola/clockula/domain/widget/StopwatchWidgetModelTest.kt new file mode 100644 index 0000000..94fdc46 --- /dev/null +++ b/app/src/test/java/de/jeanlucmakiola/clockula/domain/widget/StopwatchWidgetModelTest.kt @@ -0,0 +1,51 @@ +package de.jeanlucmakiola.clockula.domain.widget + +import com.google.common.truth.Truth.assertThat +import de.jeanlucmakiola.clockula.domain.StopwatchRun +import de.jeanlucmakiola.clockula.domain.StopwatchState +import org.junit.jupiter.api.Test +import kotlin.time.Duration.Companion.seconds + +class StopwatchWidgetModelTest { + + @Test + fun `idle reads zero with no laps`() { + val model = StopwatchWidgetPresenter.present(StopwatchRun(), elapsedRealtime = 10.seconds, lapCount = 0) + + assertThat(model.mode).isEqualTo(StopwatchState.IDLE) + assertThat(model.elapsed).isEqualTo(kotlin.time.Duration.ZERO) + assertThat(model.lapCount).isEqualTo(0) + } + + @Test + fun `running reads the live elapsed value`() { + val run = StopwatchRun(state = StopwatchState.RUNNING, startedAtElapsedRealtime = 5.seconds) + + val model = StopwatchWidgetPresenter.present(run, elapsedRealtime = 17.seconds, lapCount = 2) + + assertThat(model.mode).isEqualTo(StopwatchState.RUNNING) + assertThat(model.elapsed).isEqualTo(12.seconds) + assertThat(model.lapCount).isEqualTo(2) + } + + @Test + fun `paused reads the banked accumulated value regardless of elapsed realtime`() { + val run = StopwatchRun(state = StopwatchState.PAUSED, accumulated = 90.seconds) + + val model = StopwatchWidgetPresenter.present(run, elapsedRealtime = 500.seconds, lapCount = 3) + + assertThat(model.mode).isEqualTo(StopwatchState.PAUSED) + assertThat(model.elapsed).isEqualTo(90.seconds) + assertThat(model.lapCount).isEqualTo(3) + } + + @Test + fun `a stale running anchor reads paused at the banked value`() { + val run = StopwatchRun(state = StopwatchState.RUNNING, accumulated = 30.seconds, startedAtElapsedRealtime = null) + + val model = StopwatchWidgetPresenter.present(run, elapsedRealtime = 40.seconds, lapCount = 0) + + assertThat(model.mode).isEqualTo(StopwatchState.PAUSED) + assertThat(model.elapsed).isEqualTo(30.seconds) + } +} diff --git a/floret-kit b/floret-kit index 18a7977..6ef77bd 160000 --- a/floret-kit +++ b/floret-kit @@ -1 +1 @@ -Subproject commit 18a797747b6f27032bf674415af857a1223b6975 +Subproject commit 6ef77bd5b247024dfb997553cdcfc8fb27c84a01