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.