diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index 01acaaa..05ba4f5 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -258,8 +258,19 @@
+
+
+
+
+
+
+
+ same APPWIDGET_CONFIGURE contract as the three above. -->
@@ -300,6 +312,7 @@
@@ -311,6 +324,7 @@
@@ -322,6 +336,7 @@
@@ -333,6 +348,7 @@
@@ -344,6 +360,7 @@
diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/domain/widget/StopwatchWidgetSettings.kt b/app/src/main/java/de/jeanlucmakiola/clockula/domain/widget/StopwatchWidgetSettings.kt
new file mode 100644
index 0000000..009a430
--- /dev/null
+++ b/app/src/main/java/de/jeanlucmakiola/clockula/domain/widget/StopwatchWidgetSettings.kt
@@ -0,0 +1,14 @@
+package de.jeanlucmakiola.clockula.domain.widget
+
+/**
+ * The faces a [de.jeanlucmakiola.clockula.widget.StopwatchWidget] instance can
+ * render. [CARD] is the original full-widget card; every other style is an M3
+ * Expressive shape floating on the home screen, with the start/pause button
+ * tucked against its lower-left edge and lap/reset in a pill at its upper
+ * right. See `ui/widget/StopwatchWidgetContent.kt` for the mapping.
+ */
+enum class StopwatchWidgetStyle { CARD, CIRCLE, COOKIE, SUNNY, SQUARE }
+
+/** Anything unreadable, or a name this enum no longer has, falls back to [StopwatchWidgetStyle.CARD] — what every instance placed before styles existed shows. */
+fun parseStopwatchWidgetStyle(raw: String?): StopwatchWidgetStyle =
+ raw?.let { runCatching { StopwatchWidgetStyle.valueOf(it) }.getOrNull() } ?: StopwatchWidgetStyle.CARD
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 b986c3a..0094dfe 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
@@ -2,21 +2,31 @@ package de.jeanlucmakiola.clockula.ui.widget
import android.content.Context
import android.content.Intent
+import androidx.annotation.DrawableRes
import androidx.compose.runtime.Composable
import androidx.compose.ui.unit.dp
+import androidx.datastore.preferences.core.Preferences
+import androidx.glance.ColorFilter
import androidx.glance.GlanceModifier
import androidx.glance.GlanceTheme
+import androidx.glance.Image
+import androidx.glance.ImageProvider
import androidx.glance.LocalContext
import androidx.glance.LocalSize
+import androidx.glance.action.clickable
import androidx.glance.appwidget.action.actionSendBroadcast
import androidx.glance.appwidget.action.actionStartActivity
+import androidx.glance.background
+import androidx.glance.currentState
import androidx.glance.layout.Alignment
+import androidx.glance.layout.Box
import androidx.glance.layout.Column
import androidx.glance.layout.Row
import androidx.glance.layout.Spacer
import androidx.glance.layout.fillMaxSize
import androidx.glance.layout.height
import androidx.glance.layout.padding
+import androidx.glance.layout.size
import androidx.glance.layout.width
import androidx.glance.text.FontWeight
import androidx.glance.text.Text
@@ -26,6 +36,9 @@ import de.jeanlucmakiola.clockula.R
import de.jeanlucmakiola.clockula.domain.StopwatchState
import de.jeanlucmakiola.clockula.domain.format.ClockFormat
import de.jeanlucmakiola.clockula.domain.widget.StopwatchWidgetModel
+import de.jeanlucmakiola.clockula.domain.widget.StopwatchWidgetStyle
+import de.jeanlucmakiola.clockula.domain.widget.parseStopwatchWidgetStyle
+import de.jeanlucmakiola.clockula.widget.WIDGET_STOPWATCH_STYLE_KEY
import de.jeanlucmakiola.clockula.stopwatch.StopwatchIntents
import de.jeanlucmakiola.clockula.stopwatch.receiver.StopwatchActionReceiver
import kotlinx.coroutines.flow.map
@@ -37,14 +50,16 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.collectAsState
/**
- * Reads the stopwatch run and its laps reactively, then renders [StopwatchWidgetContent].
- * Collected *inside* the composition: `update()` on a still-live session (one
- * lives ~45s) only recomposes, so a value read once before `provideContent`
- * would keep showing what was true when the session started — the
- * `initial` value only makes the first frame right.
+ * Reads this instance's style, the stopwatch run and its laps reactively,
+ * then renders [StopwatchWidgetContent]. Collected *inside* the composition:
+ * `update()` on a still-live session (one lives ~45s) only recomposes, so a
+ * value read once before `provideContent` would keep showing what was true
+ * when the session started — the `initial` value only makes the first frame
+ * right.
*/
@Composable
fun StopwatchWidgetRoot(entryPoint: WidgetEntryPoint, initialRun: StopwatchRun, initialLapCount: Int) {
+ val style = parseStopwatchWidgetStyle(currentState()[WIDGET_STOPWATCH_STYLE_KEY])
val repository = entryPoint.stopwatchRepository()
val run by remember { repository.run() }.collectAsState(initial = initialRun)
val lapCount by remember { repository.laps().map { it.size } }.collectAsState(initial = initialLapCount)
@@ -52,19 +67,34 @@ fun StopwatchWidgetRoot(entryPoint: WidgetEntryPoint, initialRun: StopwatchRun,
// is exactly `now - elapsed`.
val elapsedRealtime = remember(run, lapCount) { entryPoint.elapsedRealtimeClock().elapsedRealtime() }
val model = remember(run, lapCount) { StopwatchWidgetPresenter.present(run, elapsedRealtime, lapCount) }
- StopwatchWidgetContent(model, elapsedRealtime.inWholeMilliseconds)
+ StopwatchWidgetContent(model, elapsedRealtime.inWholeMilliseconds, style)
}
/**
- * The stopwatch widget's content, given an already-resolved [model]: the
- * readout as large as the widget allows, a wide pill for start/pause and a
- * round lap/reset beside it. Below [WIDE_BELOW_HEIGHT] the controls move
- * beside the readout instead of under it. No 1s ticker — a running readout
- * is the platform chronometer, based at [elapsedRealtimeNow] (the
- * `SystemClock.elapsedRealtime()` millis the model was resolved at).
+ * The stopwatch widget's content, given an already-resolved [model], in
+ * [style]: [CardFace] or, for every other style, [ShapeFace]. No 1s ticker —
+ * a running readout is the platform chronometer, based at
+ * [elapsedRealtimeNow] (the `SystemClock.elapsedRealtime()` millis the model
+ * was resolved at).
*/
@Composable
-fun StopwatchWidgetContent(model: StopwatchWidgetModel, elapsedRealtimeNow: Long) {
+fun StopwatchWidgetContent(model: StopwatchWidgetModel, elapsedRealtimeNow: Long, style: StopwatchWidgetStyle) {
+ when (style) {
+ StopwatchWidgetStyle.CARD -> CardFace(model, elapsedRealtimeNow)
+ StopwatchWidgetStyle.CIRCLE -> ShapeFace(model, elapsedRealtimeNow, R.drawable.widget_bg_circle, ROUND_INSET)
+ StopwatchWidgetStyle.COOKIE -> ShapeFace(model, elapsedRealtimeNow, R.drawable.widget_shape_cookie, ROUND_INSET)
+ StopwatchWidgetStyle.SUNNY -> ShapeFace(model, elapsedRealtimeNow, R.drawable.widget_shape_sunny, ROUND_INSET)
+ StopwatchWidgetStyle.SQUARE -> ShapeFace(model, elapsedRealtimeNow, R.drawable.widget_shape_square, SQUARE_INSET)
+ }
+}
+
+/**
+ * The card: the readout as large as the widget allows, a wide pill for
+ * start/pause and a round lap/reset beside it. Below [WIDE_BELOW_HEIGHT] the
+ * controls move beside the readout instead of under it.
+ */
+@Composable
+private fun CardFace(model: StopwatchWidgetModel, elapsedRealtimeNow: Long) {
val context = LocalContext.current
val size = LocalSize.current
val w = size.width.value
@@ -118,12 +148,121 @@ fun StopwatchWidgetContent(model: StopwatchWidgetModel, elapsedRealtimeNow: Long
/** Under this height the controls sit beside the readout. */
private const val WIDE_BELOW_HEIGHT = 120f
+/**
+ * A [shape] floating on the home screen, like the analog faces, holding the
+ * readout. Start/pause is a button of its own overlapping the shape's
+ * lower-left edge — a rounded square to start or resume that rounds into a
+ * circle while it runs — and, once the stopwatch has run, lap (while running)
+ * and reset share a tertiary pill at its upper right. Both are pulled in from
+ * the square's corners by [inset] (a fraction of its side), so they straddle
+ * the shape's edge rather than float clear of it. Everything is a fraction of
+ * the square the widget fits, so the face scales as one piece.
+ */
@Composable
-private fun Readout(context: Context, model: StopwatchWidgetModel, elapsedRealtimeNow: Long, sizeDp: Float) {
+private fun ShapeFace(model: StopwatchWidgetModel, elapsedRealtimeNow: Long, @DrawableRes shape: Int, inset: Float) {
+ val context = LocalContext.current
+ val size = LocalSize.current
+ val side = minOf(size.width, size.height)
+ val s = side.value
+ val running = model.mode == StopwatchState.RUNNING
+ // The shape leaves the square's corners free, and the buttons sit there.
+ val body = s * 0.84f
+ val readoutChars = maxOf(5, ClockFormat.elapsed(model.elapsed).length)
+ // Fits the shape's inner ~62% — Sunny's and Cookie's lobes cut in deeper than a circle's edge.
+ val timeSize = minOf(s * 0.17f, body * 0.62f / (readoutChars * 0.6f))
+ val button = s * 0.28f
+ val action = s * 0.2f
+
+ CenteredSquare(side = side, onClick = actionStartActivity(showStopwatchIntent(context))) {
+ Box(modifier = GlanceModifier.fillMaxSize(), contentAlignment = Alignment.Center) {
+ Column(
+ modifier = GlanceModifier
+ .size(body.dp)
+ .background(ImageProvider(shape), colorFilter = ColorFilter.tint(GlanceTheme.colors.primaryContainer)),
+ horizontalAlignment = Alignment.CenterHorizontally,
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ Readout(context, model, elapsedRealtimeNow, timeSize, onShape = true)
+ if (model.lapCount > 0) {
+ Text(
+ text = context.getString(R.string.widget_stopwatch_laps, model.lapCount),
+ style = TextStyle(
+ color = GlanceTheme.colors.onPrimaryContainer,
+ fontSize = fittedSp(context, (s * 0.065f).coerceAtLeast(10f)),
+ fontWeight = FontWeight.Medium,
+ ),
+ maxLines = 1,
+ )
+ }
+ }
+ }
+ Box(modifier = GlanceModifier.fillMaxSize().padding((s * inset).dp), contentAlignment = Alignment.BottomStart) {
+ ShapedIcon(
+ icon = if (running) R.drawable.widget_ic_pause else R.drawable.widget_ic_play,
+ contentDescription = context.getString(startPauseLabel(model.mode)),
+ width = button.dp,
+ height = button.dp,
+ container = GlanceTheme.colors.primary,
+ content = GlanceTheme.colors.onPrimary,
+ shape = if (running) R.drawable.widget_bg_circle else R.drawable.widget_shape_square,
+ iconFraction = 0.4f,
+ onClick = actionSendBroadcast(
+ actionIntent(context, if (running) StopwatchIntents.ACTION_PAUSE else StopwatchIntents.ACTION_RESUME),
+ ),
+ )
+ }
+ if (model.mode != StopwatchState.IDLE) {
+ Box(modifier = GlanceModifier.fillMaxSize().padding((s * inset).dp), contentAlignment = Alignment.TopEnd) {
+ Row(
+ modifier = GlanceModifier
+ .padding(horizontal = (action * 0.15f).dp)
+ .background(ImageProvider(R.drawable.widget_bg_pill), colorFilter = ColorFilter.tint(GlanceTheme.colors.tertiaryContainer)),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ if (running) {
+ PillAction(R.drawable.widget_ic_flag, context.getString(R.string.stopwatch_lap), action, StopwatchIntents.ACTION_LAP)
+ }
+ PillAction(R.drawable.widget_ic_replay, context.getString(R.string.stopwatch_reset), action, StopwatchIntents.ACTION_RESET)
+ }
+ }
+ }
+ }
+}
+
+/**
+ * [ShapeFace]'s control insets. A round shape's edge on the diagonal is ~0.2
+ * of the side in from the square's corner, so its controls come in further
+ * than the Square's, whose rounded corner nearly fills the square's own.
+ */
+private const val ROUND_INSET = 0.065f
+private const val SQUARE_INSET = 0.025f
+
+/** One of [ShapeFace]'s pill actions: a bare glyph on the pill's own colour, its whole [size] the touch target. */
+@Composable
+private fun PillAction(@DrawableRes icon: Int, description: String, size: Float, action: String) {
+ val context = LocalContext.current
+ Box(
+ modifier = GlanceModifier
+ .size(size.dp)
+ .clickable(actionSendBroadcast(actionIntent(context, action))),
+ contentAlignment = Alignment.Center,
+ ) {
+ Image(
+ provider = ImageProvider(icon),
+ contentDescription = description,
+ modifier = GlanceModifier.size((size * 0.5f).dp),
+ colorFilter = ColorFilter.tint(GlanceTheme.colors.onTertiaryContainer),
+ )
+ }
+}
+
+/** [onShape]: the readout sits on [ShapeFace]'s primary-container shape rather than the card's surface. */
+@Composable
+private fun Readout(context: Context, model: StopwatchWidgetModel, elapsedRealtimeNow: Long, sizeDp: Float, onShape: Boolean = false) {
if (model.mode == StopwatchState.RUNNING) {
WidgetChronometer(
- layoutRes = R.layout.widget_chronometer,
- viewId = R.id.widget_chronometer,
+ layoutRes = if (onShape) R.layout.widget_chronometer_on_primary else R.layout.widget_chronometer,
+ viewId = if (onShape) R.id.widget_chronometer_on_primary else R.id.widget_chronometer,
base = elapsedRealtimeNow - model.elapsed.inWholeMilliseconds,
countDown = false,
textSizeDp = sizeDp,
@@ -132,11 +271,21 @@ private fun Readout(context: Context, model: StopwatchWidgetModel, elapsedRealti
WidgetReadout(
text = ClockFormat.elapsed(model.elapsed),
textSizeDp = sizeDp,
- colorRes = if (model.mode == StopwatchState.IDLE) R.color.widget_on_surface_variant else R.color.widget_on_surface,
+ colorRes = when {
+ onShape -> R.color.widget_on_primary_container
+ model.mode == StopwatchState.IDLE -> R.color.widget_on_surface_variant
+ else -> R.color.widget_on_surface
+ },
)
}
}
+private fun startPauseLabel(mode: StopwatchState): Int = when (mode) {
+ StopwatchState.IDLE -> R.string.stopwatch_start
+ StopwatchState.RUNNING -> R.string.stopwatch_pause
+ StopwatchState.PAUSED -> R.string.stopwatch_resume
+}
+
/** A wide primary pill (start/pause) and, once the stopwatch has run, a round lap/reset beside it. */
@Composable
private fun Controls(context: Context, model: StopwatchWidgetModel, button: Float) {
@@ -144,13 +293,7 @@ private fun Controls(context: Context, model: StopwatchWidgetModel, button: Floa
val running = model.mode == StopwatchState.RUNNING
ShapedIcon(
icon = if (running) R.drawable.widget_ic_pause else R.drawable.widget_ic_play,
- contentDescription = context.getString(
- when (model.mode) {
- StopwatchState.IDLE -> R.string.stopwatch_start
- StopwatchState.RUNNING -> R.string.stopwatch_pause
- StopwatchState.PAUSED -> R.string.stopwatch_resume
- },
- ),
+ contentDescription = context.getString(startPauseLabel(model.mode)),
width = (button * 1.6f).dp,
height = button.dp,
container = GlanceTheme.colors.primary,
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
new file mode 100644
index 0000000..929128e
--- /dev/null
+++ b/app/src/main/java/de/jeanlucmakiola/clockula/ui/widget/config/StopwatchConfigActivity.kt
@@ -0,0 +1,116 @@
+package de.jeanlucmakiola.clockula.ui.widget.config
+
+import android.os.SystemClock
+import android.view.MotionEvent
+import android.widget.FrameLayout
+import android.widget.RemoteViews
+import androidx.compose.foundation.layout.size
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.produceState
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.unit.DpSize
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.viewinterop.AndroidView
+import androidx.glance.appwidget.ExperimentalGlanceRemoteViewsApi
+import androidx.glance.appwidget.GlanceRemoteViews
+import androidx.glance.appwidget.state.getAppWidgetState
+import androidx.glance.appwidget.state.updateAppWidgetState
+import androidx.glance.state.PreferencesGlanceStateDefinition
+import dagger.hilt.android.AndroidEntryPoint
+import de.jeanlucmakiola.clockula.R
+import de.jeanlucmakiola.clockula.domain.StopwatchState
+import de.jeanlucmakiola.clockula.domain.widget.StopwatchWidgetModel
+import de.jeanlucmakiola.clockula.domain.widget.StopwatchWidgetStyle
+import de.jeanlucmakiola.clockula.domain.widget.parseStopwatchWidgetStyle
+import de.jeanlucmakiola.clockula.ui.widget.StopwatchWidgetContent
+import de.jeanlucmakiola.clockula.widget.StopwatchWidget
+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.
+ */
+@AndroidEntryPoint
+class StopwatchConfigActivity : WidgetConfigActivity() {
+
+ @Composable
+ override fun Content() {
+ var style by remember { mutableStateOf(StopwatchWidgetStyle.CARD) }
+ LaunchedEffect(glanceId) {
+ val state = getAppWidgetState(this@StopwatchConfigActivity, PreferencesGlanceStateDefinition, glanceId)
+ style = parseStopwatchWidgetStyle(state[WIDGET_STOPWATCH_STYLE_KEY])
+ }
+ WidgetStylePicker(
+ title = stringResource(R.string.widget_stopwatch_config_title),
+ options = StopwatchWidgetStyle.entries,
+ selected = style,
+ label = { stringResource(stopwatchStyleLabel(it)) },
+ preview = { StopwatchStylePreview(it) },
+ onSelect = { picked ->
+ style = picked
+ save {
+ updateAppWidgetState(this@StopwatchConfigActivity, glanceId) { prefs ->
+ prefs[WIDGET_STOPWATCH_STYLE_KEY] = picked.name
+ }
+ StopwatchWidget().update(this@StopwatchConfigActivity, glanceId)
+ }
+ },
+ onDismiss = ::dismiss,
+ )
+ }
+}
+
+internal fun stopwatchStyleLabel(style: StopwatchWidgetStyle): Int = when (style) {
+ StopwatchWidgetStyle.CARD -> R.string.stopwatch_style_card
+ StopwatchWidgetStyle.CIRCLE -> R.string.stopwatch_style_circle
+ StopwatchWidgetStyle.COOKIE -> R.string.analog_style_cookie
+ StopwatchWidgetStyle.SUNNY -> R.string.analog_style_sunny
+ StopwatchWidgetStyle.SQUARE -> R.string.analog_style_square
+}
+
+/**
+ * The real widget face, not a redrawing: [StopwatchWidgetContent] composed to
+ * `RemoteViews` by Glance itself and inflated in-app, showing a running
+ * stopwatch with laps banked — every control the face has. The chronometer
+ * ticks as it would on the home screen.
+ */
+@OptIn(ExperimentalGlanceRemoteViewsApi::class)
+@Composable
+private fun StopwatchStylePreview(style: StopwatchWidgetStyle) {
+ val context = LocalContext.current
+ val size = if (style == StopwatchWidgetStyle.CARD) CardPreviewSize else ShapePreviewSize
+ val views by produceState(initialValue = null, style) {
+ val now = SystemClock.elapsedRealtime()
+ value = GlanceRemoteViews().compose(context, size) {
+ StopwatchWidgetContent(PreviewModel, now, style)
+ }.remoteViews
+ }
+ AndroidView(
+ // Unlike the analog face, this one carries the widget's own click
+ // actions (open the app, start, lap, reset); it must not take touches,
+ // or a tap fires them instead of picking the style.
+ factory = { ctx ->
+ object : FrameLayout(ctx) {
+ override fun dispatchTouchEvent(event: MotionEvent): Boolean = false
+ }
+ },
+ update = { frame ->
+ frame.removeAllViews()
+ views?.let { frame.addView(it.apply(frame.context, frame)) }
+ },
+ modifier = Modifier.size(size),
+ )
+}
+
+private val PreviewModel = StopwatchWidgetModel(mode = StopwatchState.RUNNING, elapsed = 83.seconds, lapCount = 2)
+private val CardPreviewSize = DpSize(240.dp, 150.dp)
+private val ShapePreviewSize = DpSize(180.dp, 180.dp)
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 5f90ca4..c9df21e 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,9 @@ 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 stopwatch widget's `StopwatchWidgetStyle` ([name]). */
+internal val WIDGET_STOPWATCH_STYLE_KEY = stringPreferencesKey("stopwatch_style")
+
/** Per-instance Glance state key holding the quick-timer widget's configured duration, in millis. */
internal val WIDGET_QUICK_TIMER_DURATION_KEY = longPreferencesKey("quick_timer_duration_millis")
diff --git a/app/src/main/res/drawable/widget_shape_sunny.xml b/app/src/main/res/drawable/widget_shape_sunny.xml
new file mode 100644
index 0000000..13aad95
--- /dev/null
+++ b/app/src/main/res/drawable/widget_shape_sunny.xml
@@ -0,0 +1,12 @@
+
+
+
+
+
diff --git a/app/src/main/res/layout/widget_preview_next_alarm.xml b/app/src/main/res/layout/widget_preview_next_alarm.xml
new file mode 100644
index 0000000..f67b820
--- /dev/null
+++ b/app/src/main/res/layout/widget_preview_next_alarm.xml
@@ -0,0 +1,51 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/res/layout/widget_preview_quick_timer.xml b/app/src/main/res/layout/widget_preview_quick_timer.xml
new file mode 100644
index 0000000..43a52e9
--- /dev/null
+++ b/app/src/main/res/layout/widget_preview_quick_timer.xml
@@ -0,0 +1,43 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/res/layout/widget_preview_stopwatch.xml b/app/src/main/res/layout/widget_preview_stopwatch.xml
new file mode 100644
index 0000000..9885483
--- /dev/null
+++ b/app/src/main/res/layout/widget_preview_stopwatch.xml
@@ -0,0 +1,73 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/res/layout/widget_preview_timer.xml b/app/src/main/res/layout/widget_preview_timer.xml
new file mode 100644
index 0000000..724cccc
--- /dev/null
+++ b/app/src/main/res/layout/widget_preview_timer.xml
@@ -0,0 +1,53 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/res/values-night-v31/colors.xml b/app/src/main/res/values-night-v31/colors.xml
index a524639..8381b27 100644
--- a/app/src/main/res/values-night-v31/colors.xml
+++ b/app/src/main/res/values-night-v31/colors.xml
@@ -18,6 +18,9 @@
@android:color/system_accent1_200
@android:color/system_accent1_700
@android:color/system_accent1_100
+ @android:color/system_accent1_800
+ @android:color/system_accent2_700
+ @android:color/system_accent2_100
@android:color/system_accent3_200
@android:color/system_accent3_700
@android:color/system_accent3_100
diff --git a/app/src/main/res/values-night/colors.xml b/app/src/main/res/values-night/colors.xml
index 203ba07..96479f1 100644
--- a/app/src/main/res/values-night/colors.xml
+++ b/app/src/main/res/values-night/colors.xml
@@ -20,6 +20,9 @@
#FFCEE2BA
#FF4A583B
#FFE8F6D9
+ #FF213510
+ #FF3D4A35
+ #FFD8E7CB
#FFA5CEC7
#FF244D48
#FFC1ECE4
diff --git a/app/src/main/res/values-v31/colors.xml b/app/src/main/res/values-v31/colors.xml
index dbb32fa..85af93e 100644
--- a/app/src/main/res/values-v31/colors.xml
+++ b/app/src/main/res/values-v31/colors.xml
@@ -22,6 +22,9 @@
@android:color/system_accent1_600
@android:color/system_accent1_100
@android:color/system_accent1_900
+ @android:color/system_accent1_0
+ @android:color/system_accent2_100
+ @android:color/system_accent2_900
@android:color/system_accent3_600
@android:color/system_accent3_100
@android:color/system_accent3_900
diff --git a/app/src/main/res/values/colors.xml b/app/src/main/res/values/colors.xml
index ba4d168..5857ff0 100644
--- a/app/src/main/res/values/colors.xml
+++ b/app/src/main/res/values/colors.xml
@@ -34,6 +34,9 @@
#FF5C6A4E
#FFD7E8C6
#FF1D2A10
+ #FFFFFFFF
+ #FFDCE7D0
+ #FF161E10
#FF3D6660
#FFCCE8E3
#FF05201C
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index 3266d5c..cd886b3 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -358,13 +358,20 @@
Doesn\'t match what Clockula scheduled — open the app to re-check
- Next alarm
+
+ 7:00
+ 04:59
+ 01:23
+ 05:00
+ Next alarm
No alarms set
Today
- Timer
+ Timer
+ Your running timers
New timer
Done
- Clock
+ Digital clock
+ With date and world clocks
Digital clock
@@ -379,7 +386,7 @@
Up to three cities from your World clock tab
- Analog clock
+ Analog clock
Analog clock
Round
Numeral
@@ -392,11 +399,15 @@
Scallop
- Stopwatch
+ Stopwatch
Laps: %1$d
+ Stopwatch
+ Card
+ Circle
- Quick timer
+ Quick timer
+ Starts a timer with one tap
Quick timer
Start %1$s
diff --git a/app/src/main/res/xml/widget_analog_clock_info.xml b/app/src/main/res/xml/widget_analog_clock_info.xml
index 3e41e2d..8c3a4b2 100644
--- a/app/src/main/res/xml/widget_analog_clock_info.xml
+++ b/app/src/main/res/xml/widget_analog_clock_info.xml
@@ -9,7 +9,6 @@
android:updatePeriodMillis="0"
android:resizeMode="horizontal|vertical"
android:widgetCategory="home_screen"
- android:description="@string/widget_analog_description"
android:configure="de.jeanlucmakiola.clockula.ui.widget.config.AnalogClockConfigActivity"
- android:widgetFeatures="reconfigurable|configuration_optional"
+ android:widgetFeatures="reconfigurable"
android:previewLayout="@layout/widget_analog_plain" />
diff --git a/app/src/main/res/xml/widget_next_alarm_info.xml b/app/src/main/res/xml/widget_next_alarm_info.xml
index cff3cb4..8e7d58f 100644
--- a/app/src/main/res/xml/widget_next_alarm_info.xml
+++ b/app/src/main/res/xml/widget_next_alarm_info.xml
@@ -10,4 +10,4 @@
android:resizeMode="horizontal|vertical"
android:widgetCategory="home_screen"
android:previewImage="@mipmap/ic_launcher"
- android:description="@string/widget_next_alarm_description" />
+ android:previewLayout="@layout/widget_preview_next_alarm" />
diff --git a/app/src/main/res/xml/widget_quick_timer_info.xml b/app/src/main/res/xml/widget_quick_timer_info.xml
index 65e8970..7c03249 100644
--- a/app/src/main/res/xml/widget_quick_timer_info.xml
+++ b/app/src/main/res/xml/widget_quick_timer_info.xml
@@ -10,6 +10,7 @@
android:resizeMode="horizontal|vertical"
android:widgetCategory="home_screen"
android:previewImage="@mipmap/ic_launcher"
+ android:previewLayout="@layout/widget_preview_quick_timer"
android:description="@string/widget_quick_timer_description"
android:configure="de.jeanlucmakiola.clockula.ui.widget.config.QuickTimerConfigActivity"
android:widgetFeatures="reconfigurable|configuration_optional" />
diff --git a/app/src/main/res/xml/widget_stopwatch_info.xml b/app/src/main/res/xml/widget_stopwatch_info.xml
index 15a650e..f61f11f 100644
--- a/app/src/main/res/xml/widget_stopwatch_info.xml
+++ b/app/src/main/res/xml/widget_stopwatch_info.xml
@@ -10,4 +10,6 @@
android:resizeMode="horizontal|vertical"
android:widgetCategory="home_screen"
android:previewImage="@mipmap/ic_launcher"
- android:description="@string/widget_stopwatch_description" />
+ android:previewLayout="@layout/widget_preview_stopwatch"
+ android:configure="de.jeanlucmakiola.clockula.ui.widget.config.StopwatchConfigActivity"
+ android:widgetFeatures="reconfigurable" />
diff --git a/app/src/main/res/xml/widget_timer_info.xml b/app/src/main/res/xml/widget_timer_info.xml
index e198daf..46202e9 100644
--- a/app/src/main/res/xml/widget_timer_info.xml
+++ b/app/src/main/res/xml/widget_timer_info.xml
@@ -10,4 +10,5 @@
android:resizeMode="horizontal|vertical"
android:widgetCategory="home_screen"
android:previewImage="@mipmap/ic_launcher"
+ android:previewLayout="@layout/widget_preview_timer"
android:description="@string/widget_timer_description" />
diff --git a/app/src/test/java/de/jeanlucmakiola/clockula/ManifestRulesTest.kt b/app/src/test/java/de/jeanlucmakiola/clockula/ManifestRulesTest.kt
index d7905a9..be01a62 100644
--- a/app/src/test/java/de/jeanlucmakiola/clockula/ManifestRulesTest.kt
+++ b/app/src/test/java/de/jeanlucmakiola/clockula/ManifestRulesTest.kt
@@ -31,7 +31,7 @@ class ManifestRulesTest {
}
@Test
- fun `exactly the launcher, the door, the three widget configurators and the six widget receivers are exported`() {
+ fun `exactly the launcher, the door, the four widget configurators and the six widget receivers are exported`() {
val exported = Regex("<(activity|service|receiver|provider)\\b[\\s\\S]*?(?:/>|\\1>)")
.findAll(text)
.map { it.value }
@@ -43,15 +43,16 @@ class ManifestRulesTest {
// it as a widget provider at all, and `the six widget receivers
// answer only APPWIDGET_UPDATE` pins that each one is guarded to
// that one platform-delivered broadcast and nothing wider. M11 adds
- // three widget config activities: exported for the same reason —
+ // four widget config activities: exported for the same reason —
// only the launcher can start an `APPWIDGET_CONFIGURE` activity
- // cross-package — and guarded to that one action by `the three
+ // cross-package — and guarded to that one action by `the four
// widget configurators answer only APPWIDGET_CONFIGURE` below.
assertThat(exported).containsExactly(
".MainActivity",
".interop.AlarmClockActivity",
".ui.widget.config.ClockWidgetConfigActivity",
".ui.widget.config.AnalogClockConfigActivity",
+ ".ui.widget.config.StopwatchConfigActivity",
".ui.widget.config.QuickTimerConfigActivity",
".widget.NextAlarmWidgetReceiver",
".widget.TimerWidgetReceiver",
@@ -64,14 +65,15 @@ class ManifestRulesTest {
/**
* M11: the mirror of `the six widget receivers answer only
- * APPWIDGET_UPDATE` for the three widget config activities — being
+ * APPWIDGET_UPDATE` for the four widget config activities — being
* exported cannot be widened into a second, wider surface by accident.
*/
@Test
- fun `the three widget configurators answer only APPWIDGET_CONFIGURE`() {
+ fun `the four widget configurators answer only APPWIDGET_CONFIGURE`() {
val configurators = listOf(
".ui.widget.config.ClockWidgetConfigActivity",
".ui.widget.config.AnalogClockConfigActivity",
+ ".ui.widget.config.StopwatchConfigActivity",
".ui.widget.config.QuickTimerConfigActivity",
)
for (name in configurators) {
diff --git a/app/src/test/java/de/jeanlucmakiola/clockula/domain/widget/StopwatchWidgetSettingsTest.kt b/app/src/test/java/de/jeanlucmakiola/clockula/domain/widget/StopwatchWidgetSettingsTest.kt
new file mode 100644
index 0000000..89e61a3
--- /dev/null
+++ b/app/src/test/java/de/jeanlucmakiola/clockula/domain/widget/StopwatchWidgetSettingsTest.kt
@@ -0,0 +1,24 @@
+package de.jeanlucmakiola.clockula.domain.widget
+
+import com.google.common.truth.Truth.assertThat
+import org.junit.jupiter.api.Test
+
+class StopwatchWidgetSettingsTest {
+
+ @Test
+ fun `null parses to Card, so instances placed before styles existed keep their face`() {
+ assertThat(parseStopwatchWidgetStyle(null)).isEqualTo(StopwatchWidgetStyle.CARD)
+ }
+
+ @Test
+ fun `an unknown name parses to Card`() {
+ assertThat(parseStopwatchWidgetStyle("Nonexistent")).isEqualTo(StopwatchWidgetStyle.CARD)
+ }
+
+ @Test
+ fun `each stopwatch style's own name round-trips`() {
+ StopwatchWidgetStyle.entries.forEach { style ->
+ assertThat(parseStopwatchWidgetStyle(style.name)).isEqualTo(style)
+ }
+ }
+}