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:
@@ -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()
|
||||
|
||||
+79
-13
@@ -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
|
||||
|
||||
+13
-1
@@ -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))
|
||||
}
|
||||
|
||||
|
||||
+2
-3
@@ -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")
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
+25
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user