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]*?(?:/>|)") .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) + } + } +}