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
This commit is contained in:
2026-10-05 19:42:14 +02:00
parent be526711d7
commit 462f9f8c9f
18 changed files with 324 additions and 47 deletions
@@ -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
@@ -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
@@ -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<Preferences>()
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)
@@ -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<ClockWidge
val cities by remember { widgetCitiesFlow(entryPoint.worldClockRepository(), entryPoint.zoneNames()) }
.collectAsState(initial = initialCities)
ClockWidgetContent(cities = if (settings.showWorldClocks) cities else emptyList(), settings = settings)
ClockWidgetContent(
cities = if (settings.showWorldClocks) cities else emptyList(),
settings = settings,
tapTarget = parseClockWidgetTapTarget(prefs[WIDGET_TAP_TARGET_KEY]),
)
}
/**
@@ -71,13 +78,13 @@ fun ClockWidgetRoot(entryPoint: WidgetEntryPoint, initialCities: List<ClockWidge
* 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.
* the wallpaper by itself. A tap opens [tapTarget]'s tab.
*/
@Composable
fun ClockWidgetContent(cities: List<ClockWidgetCity>, settings: DigitalClockSettings) {
fun ClockWidgetContent(cities: List<ClockWidgetCity>, 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(),
@@ -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)
@@ -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)
@@ -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)
}
@@ -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()
@@ -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<AnalogClockStyle?>(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
@@ -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<DigitalClockSettings?>(null) }
var tapTarget by remember { mutableStateOf(ClockWidgetTapTarget.ALARMS) }
val cities by produceState(initialValue = emptyList<ClockWidgetCity>()) {
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<ClockWidgetCity>,
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))
}
@@ -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() {
@@ -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 },
)
}
}
@@ -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")
+3
View File
@@ -398,6 +398,9 @@
<string name="widget_config_show_world_clocks">World clocks</string>
<string name="widget_config_show_world_clocks_hint">Up to three cities from your World clock tab</string>
<!-- #8: the tab a clock widget opens when tapped; its options are the tab names -->
<string name="widget_config_on_tap">On tap</string>
<!-- M11: analog clock widget -->
<string name="widget_analog_label">Analog clock</string>
<string name="widget_analog_config_title">Analog clock</string>
@@ -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)
}
}
}
@@ -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))
}
}
@@ -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)
}
}
+7 -1
View File
@@ -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.