From 462f9f8c9fea4b4f03a40cfbd44eee5c9c6d09d1 Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Mon, 5 Oct 2026 19:41:30 +0200 Subject: [PATCH] feat(widgets): open Alarms from the clock widgets, or a tab of your choice Tapping the digital or analog clock widget now opens Alarms instead of World clock, and both config screens gain an "On tap" setting (Alarms, Timers, Stopwatch, World clock) stored per instance next to the style. Instances placed before the setting existed open Alarms. The analog config becomes a settings screen like the digital one, with the style behind a row, to make room for it. A widget tap sometimes showed whatever the app was last on instead of its tab, for two reasons. The shell replayed a deep link as a tab tap with restoreState, so it restored the tab's saved back stack, nested screen and all (fixed by the pager shell, which pops to the tab). And the widget intents carried only NEW_TASK, so with a picker or settings page left on top of MainActivity the task was merely brought forward and onNewIntent never ran. Every widget that opens the app now builds its intent in one place with CLEAR_TOP and SINGLE_TOP as well. Closes #8 --- .../domain/widget/ClockWidgetTapTarget.kt | 16 ++++ .../clockula/ui/shell/ShellNavigation.kt | 16 +++- .../ui/widget/AnalogClockWidgetContent.kt | 16 +++- .../clockula/ui/widget/ClockWidgetContent.kt | 15 ++- .../ui/widget/NextAlarmWidgetContent.kt | 6 +- .../ui/widget/StopwatchWidgetContent.kt | 6 +- .../clockula/ui/widget/TimerWidgetContent.kt | 5 +- .../clockula/ui/widget/WidgetKit.kt | 36 +++++++- .../config/AnalogClockConfigActivity.kt | 92 ++++++++++++++++--- .../config/ClockWidgetConfigActivity.kt | 14 ++- .../widget/config/StopwatchConfigActivity.kt | 5 +- .../clockula/ui/widget/config/TapTargetRow.kt | 44 +++++++++ .../clockula/widget/ClockWidgetState.kt | 6 ++ app/src/main/res/values/strings.xml | 3 + .../domain/widget/ClockWidgetTapTargetTest.kt | 25 +++++ .../clockula/ui/shell/ShellNavigationTest.kt | 20 ++++ .../clockula/ui/widget/ClockTapTargetTest.kt | 38 ++++++++ docs/ARCHITECTURE.md | 8 +- 18 files changed, 324 insertions(+), 47 deletions(-) create mode 100644 app/src/main/java/de/jeanlucmakiola/clockula/domain/widget/ClockWidgetTapTarget.kt create mode 100644 app/src/main/java/de/jeanlucmakiola/clockula/ui/widget/config/TapTargetRow.kt create mode 100644 app/src/test/java/de/jeanlucmakiola/clockula/domain/widget/ClockWidgetTapTargetTest.kt create mode 100644 app/src/test/java/de/jeanlucmakiola/clockula/ui/widget/ClockTapTargetTest.kt diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/domain/widget/ClockWidgetTapTarget.kt b/app/src/main/java/de/jeanlucmakiola/clockula/domain/widget/ClockWidgetTapTarget.kt new file mode 100644 index 0000000..7dd63d3 --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/clockula/domain/widget/ClockWidgetTapTarget.kt @@ -0,0 +1,16 @@ +package de.jeanlucmakiola.clockula.domain.widget + +/** + * Which tab a clock widget — digital or analog — opens when tapped, in tab + * order. Picked per instance in the widget's config screen and stored by + * [name] next to its style (`widget/ClockWidgetState.kt`). + * + * [ALARMS] is the default, so an instance placed before the setting existed, + * or with configuration skipped, opens Alarms: a clock on the home screen is + * most often tapped to set or check an alarm. + */ +enum class ClockWidgetTapTarget { ALARMS, TIMERS, STOPWATCH, WORLD_CLOCK } + +/** Anything unreadable, or a name this enum no longer has, falls back to [ClockWidgetTapTarget.ALARMS]. */ +fun parseClockWidgetTapTarget(raw: String?): ClockWidgetTapTarget = + raw?.let { runCatching { ClockWidgetTapTarget.valueOf(it) }.getOrNull() } ?: ClockWidgetTapTarget.ALARMS diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/ui/shell/ShellNavigation.kt b/app/src/main/java/de/jeanlucmakiola/clockula/ui/shell/ShellNavigation.kt index d37a96b..aac52fc 100644 --- a/app/src/main/java/de/jeanlucmakiola/clockula/ui/shell/ShellNavigation.kt +++ b/app/src/main/java/de/jeanlucmakiola/clockula/ui/shell/ShellNavigation.kt @@ -46,9 +46,10 @@ object ShellNavigation { /** * Internal only, like [de.jeanlucmakiola.clockula.interop.InteropIntents]' * actions: nothing outside the app sends this. It exists because the clock - * widget (M10) needs to open the World clock tab the same way a + * widgets (M10) need to open the World clock tab the same way a * notification opens its tab, and no `AlarmClock`-contract action covers a - * world clock. + * world clock. Since #8 that is one of four tabs a clock widget can open, + * and Alarms is the default. */ const val ACTION_SHOW_WORLD_CLOCK: String = "de.jeanlucmakiola.clockula.action.SHOW_WORLD_CLOCK" @@ -128,6 +129,17 @@ object ShellNavigation { else -> null } + /** + * The internal action that opens [destination] — [tabForAction]'s inverse, + * for a surface that lets the user pick the tab (the clock widgets, #8). + */ + fun showActionOf(destination: ClockulaDestination): String = when (destination) { + ClockulaDestination.ALARMS -> AlarmIntents.ACTION_SHOW_ALARMS + ClockulaDestination.TIMERS -> TimerIntents.ACTION_SHOW_TIMERS + ClockulaDestination.STOPWATCH -> StopwatchIntents.ACTION_SHOW_STOPWATCH + ClockulaDestination.WORLD_CLOCK -> ACTION_SHOW_WORLD_CLOCK + } + /** * The request an incoming *internal* intent makes of the shell. Null for an * action the shell does not answer; **degrades rather than throws** — a 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 index b392335..d1942be 100644 --- a/app/src/main/java/de/jeanlucmakiola/clockula/ui/widget/AnalogClockWidgetContent.kt +++ b/app/src/main/java/de/jeanlucmakiola/clockula/ui/widget/AnalogClockWidgetContent.kt @@ -27,14 +27,20 @@ import androidx.glance.layout.fillMaxSize import androidx.glance.layout.size import de.jeanlucmakiola.clockula.R import de.jeanlucmakiola.clockula.domain.widget.AnalogClockStyle +import de.jeanlucmakiola.clockula.domain.widget.ClockWidgetTapTarget import de.jeanlucmakiola.clockula.domain.widget.parseAnalogClockStyle +import de.jeanlucmakiola.clockula.domain.widget.parseClockWidgetTapTarget import de.jeanlucmakiola.clockula.widget.WIDGET_ANALOG_STYLE_KEY +import de.jeanlucmakiola.clockula.widget.WIDGET_TAP_TARGET_KEY -/** Reads this instance's own [AnalogClockStyle] reactively, then renders [AnalogClockWidgetContent]. */ +/** Reads this instance's own [AnalogClockStyle] and tap target reactively, then renders [AnalogClockWidgetContent]. */ @Composable fun AnalogClockWidgetRoot() { val prefs = currentState() - AnalogClockWidgetContent(style = parseAnalogClockStyle(prefs[WIDGET_ANALOG_STYLE_KEY])) + AnalogClockWidgetContent( + style = parseAnalogClockStyle(prefs[WIDGET_ANALOG_STYLE_KEY]), + tapTarget = parseClockWidgetTapTarget(prefs[WIDGET_TAP_TARGET_KEY]), + ) } /** @@ -42,13 +48,13 @@ fun AnalogClockWidgetRoot() { * 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. + * API 31+, the wallpaper by itself. A tap opens [tapTarget]'s tab. */ @Composable -fun AnalogClockWidgetContent(style: AnalogClockStyle) { +fun AnalogClockWidgetContent(style: AnalogClockStyle, tapTarget: ClockWidgetTapTarget) { val context = LocalContext.current Box( - modifier = GlanceModifier.fillMaxSize().clickable(actionStartActivity(showWorldClockIntent(context))), + modifier = GlanceModifier.fillMaxSize().clickable(actionStartActivity(clockTapIntent(context, tapTarget))), contentAlignment = Alignment.Center, ) { AnalogFace(style) 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 9bc9f79..c851011 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 @@ -17,10 +17,13 @@ import androidx.glance.layout.fillMaxSize 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.ClockWidgetTapTarget import de.jeanlucmakiola.clockula.domain.widget.DigitalClockSettings +import de.jeanlucmakiola.clockula.domain.widget.parseClockWidgetTapTarget 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.WIDGET_TAP_TARGET_KEY import de.jeanlucmakiola.clockula.widget.di.WidgetEntryPoint import de.jeanlucmakiola.clockula.widget.parseDigitalClockSettings import kotlinx.coroutines.flow.Flow @@ -63,7 +66,11 @@ fun ClockWidgetRoot(entryPoint: WidgetEntryPoint, initialCities: List, settings: DigitalClockSettings) { +fun ClockWidgetContent(cities: List, settings: DigitalClockSettings, tapTarget: ClockWidgetTapTarget) { val context = LocalContext.current val size = LocalSize.current - Box(modifier = GlanceModifier.fillMaxSize().clickable(actionStartActivity(showWorldClockIntent(context)))) { + Box(modifier = GlanceModifier.fillMaxSize().clickable(actionStartActivity(clockTapIntent(context, tapTarget)))) { AndroidRemoteViews( remoteViews = digitalClockViews(context, settings, cities, size.width.value, size.height.value), modifier = GlanceModifier.fillMaxSize(), 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 bca4546..c379141 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 @@ -19,7 +19,6 @@ 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.alarm.AlarmIntents import de.jeanlucmakiola.clockula.domain.widget.NextAlarmWidgetModel @@ -126,10 +125,7 @@ fun NextAlarmWidgetContent(model: NextAlarmWidgetModel) { } } -private fun showAlarmsIntent(context: Context): Intent = - Intent(context, MainActivity::class.java) - .setAction(AlarmIntents.ACTION_SHOW_ALARMS) - .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) +private fun showAlarmsIntent(context: Context): Intent = openAppIntent(context, AlarmIntents.ACTION_SHOW_ALARMS) private fun timeLabel(context: Context, model: NextAlarmWidgetModel): String { val time = LocalTime.of(model.hour, model.minute) 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 index 0094dfe..77eceb9 100644 --- a/app/src/main/java/de/jeanlucmakiola/clockula/ui/widget/StopwatchWidgetContent.kt +++ b/app/src/main/java/de/jeanlucmakiola/clockula/ui/widget/StopwatchWidgetContent.kt @@ -31,7 +31,6 @@ 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 @@ -323,7 +322,4 @@ private fun Controls(context: Context, model: StopwatchWidgetModel, button: Floa 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) +private fun showStopwatchIntent(context: Context): Intent = openAppIntent(context, StopwatchIntents.ACTION_SHOW_STOPWATCH) 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 b8f70fe..82bbd4b 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 @@ -22,7 +22,6 @@ 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.format.ClockFormat import de.jeanlucmakiola.clockula.domain.timer.TimerMode @@ -265,7 +264,5 @@ internal fun timerActionIntent(context: Context, action: String, timerId: Long): private fun showTimersOrComposeIntent(context: Context, model: TimerWidgetModel): Intent { val action = if (model.items.isEmpty()) InteropIntents.ACTION_COMPOSE_TIMER else TimerIntents.ACTION_SHOW_TIMERS - return Intent(context, MainActivity::class.java) - .setAction(action) - .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + return openAppIntent(context, action) } 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 index 0ef13b3..8f02ada 100644 --- a/app/src/main/java/de/jeanlucmakiola/clockula/ui/widget/WidgetKit.kt +++ b/app/src/main/java/de/jeanlucmakiola/clockula/ui/widget/WidgetKit.kt @@ -33,6 +33,8 @@ import androidx.glance.layout.wrapContentSize import androidx.glance.unit.ColorProvider import de.jeanlucmakiola.clockula.MainActivity import de.jeanlucmakiola.clockula.R +import de.jeanlucmakiola.clockula.domain.widget.ClockWidgetTapTarget +import de.jeanlucmakiola.clockula.ui.shell.ClockulaDestination import de.jeanlucmakiola.clockula.ui.theme.ClockulaDarkFallback import de.jeanlucmakiola.clockula.ui.theme.ClockulaLightFallback import de.jeanlucmakiola.clockula.ui.shell.ShellNavigation @@ -184,11 +186,37 @@ internal fun CenteredSquare(side: Dp, onClick: Action, content: @Composable () - } } -/** Opens the app on World clock — where both clock widgets lead. */ -internal fun showWorldClockIntent(context: Context): Intent = +/** + * Opens the app for [action] — one of the internal actions `MainActivity` + * answers — from a widget tap. Every widget that opens the app goes through + * here, so every one lands where it says, whatever the app was showing. + * + * `CLEAR_TOP` + `SINGLE_TOP`, not `NEW_TASK` alone: with `NEW_TASK` alone an + * existing task is only brought forward, and if anything sits on top of + * `MainActivity` in it — a ringtone or file picker the editor opened, a + * system settings page — the intent is never delivered (or stacks a second + * `MainActivity` on it), and the user is shown whatever they left open. + * These finish anything above `MainActivity` and hand the intent to the + * running instance's `onNewIntent`, where the shell pops any nested screen + * back to the asked-for tab (`ShellNavigation.onTabRequested`, #8). + */ +internal fun openAppIntent(context: Context, action: String): Intent = Intent(context, MainActivity::class.java) - .setAction(ShellNavigation.ACTION_SHOW_WORLD_CLOCK) - .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + .setAction(action) + .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_SINGLE_TOP) + +/** Opens the app on [target] — the tab a clock widget instance was set to open (#8). */ +internal fun clockTapIntent(context: Context, target: ClockWidgetTapTarget): Intent = + openAppIntent(context, ShellNavigation.showActionOf(target.destination)) + +/** The shell tab [this] opens. */ +internal val ClockWidgetTapTarget.destination: ClockulaDestination + get() = when (this) { + ClockWidgetTapTarget.ALARMS -> ClockulaDestination.ALARMS + ClockWidgetTapTarget.TIMERS -> ClockulaDestination.TIMERS + ClockWidgetTapTarget.STOPWATCH -> ClockulaDestination.STOPWATCH + ClockWidgetTapTarget.WORLD_CLOCK -> ClockulaDestination.WORLD_CLOCK + } 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 index 2d9b6cf..2c67337 100644 --- 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 @@ -1,13 +1,21 @@ package de.jeanlucmakiola.clockula.ui.widget.config import android.widget.FrameLayout +import androidx.compose.foundation.layout.Box +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.layout.size +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf 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 @@ -19,37 +27,49 @@ import androidx.glance.state.PreferencesGlanceStateDefinition import dagger.hilt.android.AndroidEntryPoint import de.jeanlucmakiola.clockula.R import de.jeanlucmakiola.clockula.domain.widget.AnalogClockStyle +import de.jeanlucmakiola.clockula.domain.widget.ClockWidgetTapTarget import de.jeanlucmakiola.clockula.domain.widget.parseAnalogClockStyle +import de.jeanlucmakiola.clockula.domain.widget.parseClockWidgetTapTarget 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.clockula.widget.WIDGET_TAP_TARGET_KEY +import de.jeanlucmakiola.floret.components.FullScreenPicker +import de.jeanlucmakiola.floret.components.GroupedRow +import de.jeanlucmakiola.floret.components.Position /** - * 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). + * The analog widget's configuration: its style and the tab a tap opens (#8). + * Laid out like the digital widget's — the live face above the settings, the + * style behind a row that opens [WidgetStylePicker], saved with OK — since a + * second setting means a tap on a style can no longer be the whole answer. */ @AndroidEntryPoint class AnalogClockConfigActivity : WidgetConfigActivity() { @Composable override fun Content() { - var style by remember { mutableStateOf(AnalogClockStyle.ROUND) } + // `null` until this instance's stored settings are read, as in the + // digital config: neither an early save nor a late load can write + // defaults over what the user has. + var style by remember { mutableStateOf(null) } + var tapTarget by remember { mutableStateOf(ClockWidgetTapTarget.ALARMS) } LaunchedEffect(glanceId) { val state = getAppWidgetState(this@AnalogClockConfigActivity, PreferencesGlanceStateDefinition, glanceId) + tapTarget = parseClockWidgetTapTarget(state[WIDGET_TAP_TARGET_KEY]) style = parseAnalogClockStyle(state[WIDGET_ANALOG_STYLE_KEY]) } - 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 + val current = style ?: return + AnalogClockConfigScreen( + style = current, + onStyleChange = { style = it }, + tapTarget = tapTarget, + onTapTargetChange = { tapTarget = it }, + onSave = { save { updateAppWidgetState(this@AnalogClockConfigActivity, glanceId) { prefs -> - prefs[WIDGET_ANALOG_STYLE_KEY] = picked.name + prefs[WIDGET_ANALOG_STYLE_KEY] = current.name + prefs[WIDGET_TAP_TARGET_KEY] = tapTarget.name } AnalogClockWidget().update(this@AnalogClockConfigActivity, glanceId) } @@ -59,6 +79,52 @@ class AnalogClockConfigActivity : WidgetConfigActivity() { } } +@Composable +private fun AnalogClockConfigScreen( + style: AnalogClockStyle, + onStyleChange: (AnalogClockStyle) -> Unit, + tapTarget: ClockWidgetTapTarget, + onTapTargetChange: (ClockWidgetTapTarget) -> Unit, + onSave: () -> Unit, + onDismiss: () -> Unit, +) { + var showStylePicker by remember { mutableStateOf(false) } + + FullScreenPicker( + title = stringResource(R.string.widget_analog_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) { + AnalogStylePreview(style) + } + GroupedRow( + title = stringResource(R.string.widget_config_style), + summary = stringResource(analogStyleLabel(style)), + position = Position.Alone, + onClick = { showStylePicker = true }, + ) + Spacer(Modifier.height(8.dp)) + TapTargetRow(target = tapTarget, position = Position.Alone, onTargetChange = onTapTargetChange) + Spacer(Modifier.height(24.dp)) + } + + if (showStylePicker) { + WidgetStylePicker( + title = stringResource(R.string.widget_config_style), + options = AnalogClockStyle.entries, + selected = style, + label = { stringResource(analogStyleLabel(it)) }, + preview = { AnalogStylePreview(it) }, + onSelect = onStyleChange, + onDismiss = { showStylePicker = false }, + ) + } +} + internal fun analogStyleLabel(style: AnalogClockStyle): Int = when (style) { AnalogClockStyle.ROUND -> R.string.analog_style_round AnalogClockStyle.NUMERAL -> R.string.analog_style_numeral 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 index 153f79c..57bab5b 100644 --- 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 @@ -30,8 +30,10 @@ import dagger.hilt.android.AndroidEntryPoint 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.ClockWidgetTapTarget import de.jeanlucmakiola.clockula.domain.widget.DigitalClockSettings import de.jeanlucmakiola.clockula.domain.widget.DigitalClockStyle +import de.jeanlucmakiola.clockula.domain.widget.parseClockWidgetTapTarget import de.jeanlucmakiola.clockula.domain.widget.showsWorldClocks import de.jeanlucmakiola.clockula.ui.widget.ClockWidgetCity import de.jeanlucmakiola.clockula.ui.widget.digitalClockViews @@ -40,13 +42,14 @@ 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.WIDGET_TAP_TARGET_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 javax.inject.Inject -/** Picks the digital widget instance's style and the two switches. */ +/** Picks the digital widget instance's style, the two switches, and the tab a tap opens. */ @AndroidEntryPoint class ClockWidgetConfigActivity : WidgetConfigActivity() { @@ -59,11 +62,13 @@ class ClockWidgetConfigActivity : WidgetConfigActivity() { // (and its OK) only appears then, so neither an early save nor a // late load can write defaults over what the user has. var settings by remember { mutableStateOf(null) } + var tapTarget by remember { mutableStateOf(ClockWidgetTapTarget.ALARMS) } val cities by produceState(initialValue = emptyList()) { value = widgetCities(worldClockRepository, zoneNames) } LaunchedEffect(glanceId) { val state = getAppWidgetState(this@ClockWidgetConfigActivity, PreferencesGlanceStateDefinition, glanceId) + tapTarget = parseClockWidgetTapTarget(state[WIDGET_TAP_TARGET_KEY]) settings = parseDigitalClockSettings( style = state[WIDGET_DIGITAL_STYLE_KEY], showDate = state[WIDGET_SHOW_DATE_KEY], @@ -75,12 +80,15 @@ class ClockWidgetConfigActivity : WidgetConfigActivity() { settings = current, cities = cities, onSettingsChange = { settings = it }, + tapTarget = tapTarget, + onTapTargetChange = { tapTarget = it }, onSave = { save { updateAppWidgetState(this@ClockWidgetConfigActivity, glanceId) { prefs -> prefs[WIDGET_DIGITAL_STYLE_KEY] = current.style.name prefs[WIDGET_SHOW_DATE_KEY] = current.showDate prefs[WIDGET_SHOW_WORLD_CLOCKS_KEY] = current.showWorldClocks + prefs[WIDGET_TAP_TARGET_KEY] = tapTarget.name } ClockWidget().update(this@ClockWidgetConfigActivity, glanceId) } @@ -95,6 +103,8 @@ private fun ClockWidgetConfigScreen( settings: DigitalClockSettings, cities: List, onSettingsChange: (DigitalClockSettings) -> Unit, + tapTarget: ClockWidgetTapTarget, + onTapTargetChange: (ClockWidgetTapTarget) -> Unit, onSave: () -> Unit, onDismiss: () -> Unit, ) { @@ -134,6 +144,8 @@ private fun ClockWidgetConfigScreen( onClick = { onSettingsChange(settings.copy(showWorldClocks = !settings.showWorldClocks)) }, ) } + Spacer(Modifier.height(8.dp)) + TapTargetRow(target = tapTarget, position = Position.Alone, onTargetChange = onTapTargetChange) Spacer(Modifier.height(24.dp)) } diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/ui/widget/config/StopwatchConfigActivity.kt b/app/src/main/java/de/jeanlucmakiola/clockula/ui/widget/config/StopwatchConfigActivity.kt index 929128e..916f312 100644 --- a/app/src/main/java/de/jeanlucmakiola/clockula/ui/widget/config/StopwatchConfigActivity.kt +++ b/app/src/main/java/de/jeanlucmakiola/clockula/ui/widget/config/StopwatchConfigActivity.kt @@ -35,9 +35,8 @@ import de.jeanlucmakiola.clockula.widget.WIDGET_STOPWATCH_STYLE_KEY import kotlin.time.Duration.Companion.seconds /** - * The stopwatch widget's entire configuration: a style pick. Like - * [AnalogClockConfigActivity], tapping an option saves and finishes - * immediately. + * The stopwatch widget's entire configuration: a style pick. Tapping an + * option saves and finishes immediately. */ @AndroidEntryPoint class StopwatchConfigActivity : WidgetConfigActivity() { diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/ui/widget/config/TapTargetRow.kt b/app/src/main/java/de/jeanlucmakiola/clockula/ui/widget/config/TapTargetRow.kt new file mode 100644 index 0000000..c093913 --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/clockula/ui/widget/config/TapTargetRow.kt @@ -0,0 +1,44 @@ +package de.jeanlucmakiola.clockula.ui.widget.config + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.res.stringResource +import de.jeanlucmakiola.clockula.R +import de.jeanlucmakiola.clockula.domain.widget.ClockWidgetTapTarget +import de.jeanlucmakiola.clockula.ui.widget.destination +import de.jeanlucmakiola.floret.components.GroupedRow +import de.jeanlucmakiola.floret.components.OptionPicker +import de.jeanlucmakiola.floret.components.Position + +/** + * A clock widget's "On tap" setting (#8): a row summarising which tab a tap + * opens, and a picker of the four tabs behind it, named exactly as the + * navigation bar names them. + */ +@Composable +internal fun TapTargetRow( + target: ClockWidgetTapTarget, + position: Position, + onTargetChange: (ClockWidgetTapTarget) -> Unit, +) { + var showPicker by remember { mutableStateOf(false) } + GroupedRow( + title = stringResource(R.string.widget_config_on_tap), + summary = stringResource(target.destination.titleRes), + position = position, + onClick = { showPicker = true }, + ) + if (showPicker) { + OptionPicker( + title = stringResource(R.string.widget_config_on_tap), + options = ClockWidgetTapTarget.entries, + selected = target, + label = { stringResource(it.destination.titleRes) }, + onSelect = onTargetChange, + onDismiss = { showPicker = false }, + ) + } +} diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/widget/ClockWidgetState.kt b/app/src/main/java/de/jeanlucmakiola/clockula/widget/ClockWidgetState.kt index c9df21e..aba429c 100644 --- a/app/src/main/java/de/jeanlucmakiola/clockula/widget/ClockWidgetState.kt +++ b/app/src/main/java/de/jeanlucmakiola/clockula/widget/ClockWidgetState.kt @@ -20,6 +20,12 @@ internal val WIDGET_SHOW_WORLD_CLOCKS_KEY = booleanPreferencesKey("show_world_cl /** 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 tab a clock widget — digital or + * analog — opens on tap: a `ClockWidgetTapTarget` ([name]). + */ +internal val WIDGET_TAP_TARGET_KEY = stringPreferencesKey("tap_target") + /** Per-instance Glance state key holding the stopwatch widget's `StopwatchWidgetStyle` ([name]). */ internal val WIDGET_STOPWATCH_STYLE_KEY = stringPreferencesKey("stopwatch_style") diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 96fac52..7472e44 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -398,6 +398,9 @@ World clocks Up to three cities from your World clock tab + + On tap + Analog clock Analog clock diff --git a/app/src/test/java/de/jeanlucmakiola/clockula/domain/widget/ClockWidgetTapTargetTest.kt b/app/src/test/java/de/jeanlucmakiola/clockula/domain/widget/ClockWidgetTapTargetTest.kt new file mode 100644 index 0000000..31215c1 --- /dev/null +++ b/app/src/test/java/de/jeanlucmakiola/clockula/domain/widget/ClockWidgetTapTargetTest.kt @@ -0,0 +1,25 @@ +package de.jeanlucmakiola.clockula.domain.widget + +import com.google.common.truth.Truth.assertThat +import org.junit.jupiter.api.Test + +class ClockWidgetTapTargetTest { + + @Test + fun `null parses to Alarms, so clock widgets placed before the setting existed open Alarms`() { + assertThat(parseClockWidgetTapTarget(null)).isEqualTo(ClockWidgetTapTarget.ALARMS) + } + + @Test + fun `an unknown name parses to Alarms`() { + assertThat(parseClockWidgetTapTarget("Nonexistent")).isEqualTo(ClockWidgetTapTarget.ALARMS) + assertThat(parseClockWidgetTapTarget("")).isEqualTo(ClockWidgetTapTarget.ALARMS) + } + + @Test + fun `each tap target's own name round-trips`() { + ClockWidgetTapTarget.entries.forEach { target -> + assertThat(parseClockWidgetTapTarget(target.name)).isEqualTo(target) + } + } +} diff --git a/app/src/test/java/de/jeanlucmakiola/clockula/ui/shell/ShellNavigationTest.kt b/app/src/test/java/de/jeanlucmakiola/clockula/ui/shell/ShellNavigationTest.kt index c00023c..191da02 100644 --- a/app/src/test/java/de/jeanlucmakiola/clockula/ui/shell/ShellNavigationTest.kt +++ b/app/src/test/java/de/jeanlucmakiola/clockula/ui/shell/ShellNavigationTest.kt @@ -238,4 +238,24 @@ class ShellNavigationTest { assertThat(answers).containsExactly(ClockulaDestination.TIMERS, null, null).inOrder() } + + // --- #8: a widget picks the tab it opens --- + + @Test + fun `every tab's show action asks for that tab`() { + val resolved = ClockulaDestination.entries.map { + ShellNavigation.tabForAction(ShellNavigation.showActionOf(it)) + } + + assertThat(resolved).isEqualTo(ClockulaDestination.entries.toList()) + } + + @Test + fun `every tab's show action opens it as a plain tab request`() { + val requests = ClockulaDestination.entries.map { + ShellNavigation.requestFor(ShellNavigation.showActionOf(it), null, emptyList()) + } + + assertThat(requests).isEqualTo(ClockulaDestination.entries.map(ShellRequest::OpenTab)) + } } diff --git a/app/src/test/java/de/jeanlucmakiola/clockula/ui/widget/ClockTapTargetTest.kt b/app/src/test/java/de/jeanlucmakiola/clockula/ui/widget/ClockTapTargetTest.kt new file mode 100644 index 0000000..cc8d464 --- /dev/null +++ b/app/src/test/java/de/jeanlucmakiola/clockula/ui/widget/ClockTapTargetTest.kt @@ -0,0 +1,38 @@ +package de.jeanlucmakiola.clockula.ui.widget + +import com.google.common.truth.Truth.assertThat +import de.jeanlucmakiola.clockula.domain.widget.ClockWidgetTapTarget +import de.jeanlucmakiola.clockula.domain.widget.parseClockWidgetTapTarget +import de.jeanlucmakiola.clockula.ui.shell.ClockulaDestination +import de.jeanlucmakiola.clockula.ui.shell.ShellNavigation +import org.junit.jupiter.api.Test + +/** + * A clock widget's tap (#8) reaches `MainActivity` as an action, and the shell + * maps that action back to a tab: the round trip has to come out on the tab + * the user picked, for every one of them. + */ +class ClockTapTargetTest { + + @Test + fun `every tap target is the tab of the same name, in the same order`() { + val destinations = ClockWidgetTapTarget.entries.map { it.destination } + + assertThat(destinations).isEqualTo(ClockulaDestination.entries.toList()) + ClockWidgetTapTarget.entries.forEach { assertThat(it.destination.name).isEqualTo(it.name) } + } + + @Test + fun `every tap target's action opens its own tab`() { + ClockWidgetTapTarget.entries.forEach { target -> + val action = ShellNavigation.showActionOf(target.destination) + + assertThat(ShellNavigation.tabForAction(action)).isEqualTo(target.destination) + } + } + + @Test + fun `a widget with no stored tap target opens alarms`() { + assertThat(parseClockWidgetTapTarget(null).destination).isEqualTo(ClockulaDestination.ALARMS) + } +} diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 7d8e039..eacea2f 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -1334,7 +1334,13 @@ The policy the shell follows is a pure function, `ShellNavigation`: one loses nothing; - a tab an intent asks for (a notification, a widget, the exported door) is the same pop and a jump — it always lands on the tab itself, never on whatever - nested screen was last open there; + nested screen was last open there. Widgets build that intent in one place, + `openAppIntent`, with `CLEAR_TOP` + `SINGLE_TOP` beside `NEW_TASK`, so an + activity left on top of `MainActivity` in its task (a picker the editor + opened) is finished and the intent reaches `onNewIntent` rather than the + task merely being brought forward. The two clock widgets open the tab picked + per instance in their config screen (`ClockWidgetTapTarget`, stored next to + the style; Alarms when unset — #8); - back from any of the other three tabs returns to **Alarms**; back from Alarms and from a null route leaves the app, and a nested screen's back belongs to the `NavController`, not to us.