feat(widgets): stopwatch style picker and widget-picker previews

The stopwatch widget gets a per-instance style, chosen in a new
StopwatchConfigActivity on the APPWIDGET_CONFIGURE contract. Every widget
receiver now carries its own label, with a separate description, and the
widget picker shows sample-value preview layouts instead of a bare name.
This commit is contained in:
2026-10-02 19:16:36 +02:00
parent 7f2b047182
commit bad996d946
22 changed files with 617 additions and 40 deletions
+18 -1
View File
@@ -258,8 +258,19 @@
</intent-filter>
</activity>
<!-- The stopwatch widget's per-instance style picker: the same
APPWIDGET_CONFIGURE contract as the two above. -->
<activity
android:name=".ui.widget.config.StopwatchConfigActivity"
android:exported="true"
android:theme="@style/Theme.Clockula">
<intent-filter>
<action android:name="android.appwidget.action.APPWIDGET_CONFIGURE" />
</intent-filter>
</activity>
<!-- The quick-timer widget's per-instance duration picker (M11): the
same APPWIDGET_CONFIGURE contract as the two above. -->
same APPWIDGET_CONFIGURE contract as the three above. -->
<activity
android:name=".ui.widget.config.QuickTimerConfigActivity"
android:exported="true"
@@ -289,6 +300,7 @@
change (widget/AnalogDateRefresh.kt). -->
<receiver
android:name=".widget.NextAlarmWidgetReceiver"
android:label="@string/widget_next_alarm_label"
android:exported="true">
<intent-filter>
<action android:name="android.appwidget.action.APPWIDGET_UPDATE" />
@@ -300,6 +312,7 @@
<receiver
android:name=".widget.TimerWidgetReceiver"
android:label="@string/widget_timer_label"
android:exported="true">
<intent-filter>
<action android:name="android.appwidget.action.APPWIDGET_UPDATE" />
@@ -311,6 +324,7 @@
<receiver
android:name=".widget.ClockWidgetReceiver"
android:label="@string/widget_clock_label"
android:exported="true">
<intent-filter>
<action android:name="android.appwidget.action.APPWIDGET_UPDATE" />
@@ -322,6 +336,7 @@
<receiver
android:name=".widget.AnalogClockWidgetReceiver"
android:label="@string/widget_analog_label"
android:exported="true">
<intent-filter>
<action android:name="android.appwidget.action.APPWIDGET_UPDATE" />
@@ -333,6 +348,7 @@
<receiver
android:name=".widget.StopwatchWidgetReceiver"
android:label="@string/widget_stopwatch_label"
android:exported="true">
<intent-filter>
<action android:name="android.appwidget.action.APPWIDGET_UPDATE" />
@@ -344,6 +360,7 @@
<receiver
android:name=".widget.QuickTimerWidgetReceiver"
android:label="@string/widget_quick_timer_label"
android:exported="true">
<intent-filter>
<action android:name="android.appwidget.action.APPWIDGET_UPDATE" />
@@ -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
@@ -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<Preferences>()[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,
@@ -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<RemoteViews?>(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)
@@ -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")
@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- `MaterialShapes.Sunny` (`values/widget_shapes.xml`), white so its user
tints it — see `widget_bg_card.xml`. -->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="48dp"
android:height="48dp"
android:viewportWidth="100"
android:viewportHeight="100">
<path
android:pathData="@string/widget_shape_path_sunny"
android:fillColor="#FFFFFFFF" />
</vector>
@@ -0,0 +1,51 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- The widget picker's preview of the next-alarm widget (`previewLayout`,
API 31+): a static stand-in for `ui/widget/NextAlarmWidgetContent.kt` —
the Cookie alarm badge, a day, a time and a label on the surface card. -->
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="@drawable/widget_bg_card"
android:backgroundTint="@color/widget_surface"
android:gravity="center_vertical"
android:orientation="horizontal"
android:padding="14dp">
<ImageView
android:layout_width="52dp"
android:layout_height="52dp"
android:background="@drawable/widget_shape_cookie"
android:backgroundTint="@color/widget_primary_container"
android:importantForAccessibility="no"
android:padding="13dp"
android:src="@drawable/widget_ic_alarm"
android:tint="@color/widget_on_primary_container" />
<LinearLayout
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="12dp"
android:orientation="vertical">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:fontFamily="sans-serif-medium"
android:maxLines="1"
android:text="@string/widget_next_alarm_today"
android:textColor="@color/widget_primary"
android:textSize="13sp" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:fontFamily="sans-serif-medium"
android:includeFontPadding="false"
android:maxLines="1"
android:text="@string/widget_preview_alarm_time"
android:textColor="@color/widget_on_surface"
android:textSize="30sp" />
</LinearLayout>
</LinearLayout>
@@ -0,0 +1,43 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- The widget picker's preview of the quick-timer widget (`previewLayout`,
API 31+): a static stand-in for `ui/widget/QuickTimerWidgetContent.kt`
at rest — the Scallop with a play glyph over its default 05:00. -->
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent">
<ImageView
android:layout_width="match_parent"
android:layout_height="match_parent"
android:importantForAccessibility="no"
android:scaleType="fitCenter"
android:src="@drawable/widget_shape_scallop"
android:tint="@color/widget_primary_container" />
<LinearLayout
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:gravity="center_horizontal"
android:orientation="vertical">
<ImageView
android:layout_width="18dp"
android:layout_height="18dp"
android:importantForAccessibility="no"
android:src="@drawable/widget_ic_play"
android:tint="@color/widget_on_primary_container" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:fontFamily="sans-serif-medium"
android:includeFontPadding="false"
android:maxLines="1"
android:text="@string/widget_preview_quick_timer_time"
android:textColor="@color/widget_on_primary_container"
android:textSize="14sp" />
</LinearLayout>
</FrameLayout>
@@ -0,0 +1,73 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- The widget picker's preview of the stopwatch widget (`previewLayout`,
API 31+): a static stand-in for `ui/widget/StopwatchWidgetContent.kt`'s
Circle face, running — the shape with its readout, the circular pause
against its lower-left edge, lap and reset in the pill at its upper
right. Fixed at a size that fits two cells. -->
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent">
<FrameLayout
android:layout_width="112dp"
android:layout_height="112dp"
android:layout_gravity="center">
<TextView
android:layout_width="94dp"
android:layout_height="94dp"
android:layout_gravity="center"
android:background="@drawable/widget_bg_circle"
android:backgroundTint="@color/widget_primary_container"
android:fontFamily="sans-serif-medium"
android:gravity="center"
android:includeFontPadding="false"
android:maxLines="1"
android:text="@string/widget_preview_stopwatch_time"
android:textColor="@color/widget_on_primary_container"
android:textSize="19sp" />
<ImageView
android:layout_width="31dp"
android:layout_height="31dp"
android:layout_gravity="bottom|start"
android:layout_margin="7dp"
android:background="@drawable/widget_bg_circle"
android:backgroundTint="@color/widget_primary"
android:importantForAccessibility="no"
android:padding="9dp"
android:src="@drawable/widget_ic_pause"
android:tint="@color/widget_on_primary" />
<LinearLayout
android:layout_width="wrap_content"
android:layout_height="22dp"
android:layout_gravity="top|end"
android:layout_margin="7dp"
android:background="@drawable/widget_bg_pill"
android:backgroundTint="@color/widget_tertiary_container"
android:gravity="center_vertical"
android:orientation="horizontal"
android:paddingHorizontal="4dp">
<ImageView
android:layout_width="22dp"
android:layout_height="22dp"
android:importantForAccessibility="no"
android:padding="5dp"
android:src="@drawable/widget_ic_flag"
android:tint="@color/widget_on_tertiary_container" />
<ImageView
android:layout_width="22dp"
android:layout_height="22dp"
android:importantForAccessibility="no"
android:padding="5dp"
android:src="@drawable/widget_ic_replay"
android:tint="@color/widget_on_tertiary_container" />
</LinearLayout>
</FrameLayout>
</FrameLayout>
@@ -0,0 +1,53 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- The widget picker's preview of the timer widget (`previewLayout`,
API 31+): a static stand-in for `ui/widget/TimerWidgetContent.kt`'s lone
running timer — readout over a circular pause and a reset. -->
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="@drawable/widget_bg_card"
android:backgroundTint="@color/widget_surface"
android:gravity="center"
android:orientation="vertical"
android:padding="14dp">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:fontFamily="sans-serif-medium"
android:includeFontPadding="false"
android:maxLines="1"
android:text="@string/widget_preview_timer_time"
android:textColor="@color/widget_on_surface"
android:textSize="40sp" />
<LinearLayout
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="12dp"
android:orientation="horizontal">
<ImageView
android:layout_width="44dp"
android:layout_height="44dp"
android:background="@drawable/widget_bg_circle"
android:backgroundTint="@color/widget_primary"
android:importantForAccessibility="no"
android:padding="11dp"
android:src="@drawable/widget_ic_pause"
android:tint="@color/widget_on_primary" />
<ImageView
android:layout_width="44dp"
android:layout_height="44dp"
android:layout_marginStart="8dp"
android:background="@drawable/widget_bg_circle"
android:backgroundTint="@color/widget_secondary_container"
android:importantForAccessibility="no"
android:padding="11dp"
android:src="@drawable/widget_ic_replay"
android:tint="@color/widget_on_secondary_container" />
</LinearLayout>
</LinearLayout>
@@ -18,6 +18,9 @@
<color name="widget_primary">@android:color/system_accent1_200</color>
<color name="widget_primary_container">@android:color/system_accent1_700</color>
<color name="widget_on_primary_container">@android:color/system_accent1_100</color>
<color name="widget_on_primary">@android:color/system_accent1_800</color>
<color name="widget_secondary_container">@android:color/system_accent2_700</color>
<color name="widget_on_secondary_container">@android:color/system_accent2_100</color>
<color name="widget_tertiary">@android:color/system_accent3_200</color>
<color name="widget_tertiary_container">@android:color/system_accent3_700</color>
<color name="widget_on_tertiary_container">@android:color/system_accent3_100</color>
+3
View File
@@ -20,6 +20,9 @@
<color name="widget_primary">#FFCEE2BA</color>
<color name="widget_primary_container">#FF4A583B</color>
<color name="widget_on_primary_container">#FFE8F6D9</color>
<color name="widget_on_primary">#FF213510</color>
<color name="widget_secondary_container">#FF3D4A35</color>
<color name="widget_on_secondary_container">#FFD8E7CB</color>
<color name="widget_tertiary">#FFA5CEC7</color>
<color name="widget_tertiary_container">#FF244D48</color>
<color name="widget_on_tertiary_container">#FFC1ECE4</color>
+3
View File
@@ -22,6 +22,9 @@
<color name="widget_primary">@android:color/system_accent1_600</color>
<color name="widget_primary_container">@android:color/system_accent1_100</color>
<color name="widget_on_primary_container">@android:color/system_accent1_900</color>
<color name="widget_on_primary">@android:color/system_accent1_0</color>
<color name="widget_secondary_container">@android:color/system_accent2_100</color>
<color name="widget_on_secondary_container">@android:color/system_accent2_900</color>
<color name="widget_tertiary">@android:color/system_accent3_600</color>
<color name="widget_tertiary_container">@android:color/system_accent3_100</color>
<color name="widget_on_tertiary_container">@android:color/system_accent3_900</color>
+3
View File
@@ -34,6 +34,9 @@
<color name="widget_primary">#FF5C6A4E</color>
<color name="widget_primary_container">#FFD7E8C6</color>
<color name="widget_on_primary_container">#FF1D2A10</color>
<color name="widget_on_primary">#FFFFFFFF</color>
<color name="widget_secondary_container">#FFDCE7D0</color>
<color name="widget_on_secondary_container">#FF161E10</color>
<color name="widget_tertiary">#FF3D6660</color>
<color name="widget_tertiary_container">#FFCCE8E3</color>
<color name="widget_on_tertiary_container">#FF05201C</color>
+17 -6
View File
@@ -358,13 +358,20 @@
<string name="selfcheck_next_alarm_problem">Doesn\'t match what Clockula scheduled — open the app to re-check</string>
<!-- M10: widgets -->
<string name="widget_next_alarm_description">Next alarm</string>
<!-- Sample values for the widget picker's previews (`layout/widget_preview_*.xml`). -->
<string name="widget_preview_alarm_time" translatable="false">7:00</string>
<string name="widget_preview_timer_time" translatable="false">04:59</string>
<string name="widget_preview_stopwatch_time" translatable="false">01:23</string>
<string name="widget_preview_quick_timer_time" translatable="false">05:00</string>
<string name="widget_next_alarm_label">Next alarm</string>
<string name="widget_next_alarm_empty">No alarms set</string>
<string name="widget_next_alarm_today">Today</string>
<string name="widget_timer_description">Timer</string>
<string name="widget_timer_label">Timer</string>
<string name="widget_timer_description">Your running timers</string>
<string name="widget_timer_new">New timer</string>
<string name="widget_timer_expired">Done</string>
<string name="widget_clock_description">Clock</string>
<string name="widget_clock_label">Digital clock</string>
<string name="widget_clock_description">With date and world clocks</string>
<!-- M11: digital clock widget faces + per-instance config -->
<string name="widget_config_title">Digital clock</string>
@@ -379,7 +386,7 @@
<string name="widget_config_show_world_clocks_hint">Up to three cities from your World clock tab</string>
<!-- M11: analog clock widget -->
<string name="widget_analog_description">Analog clock</string>
<string name="widget_analog_label">Analog clock</string>
<string name="widget_analog_config_title">Analog clock</string>
<string name="analog_style_round">Round</string>
<string name="analog_style_numeral">Numeral</string>
@@ -392,11 +399,15 @@
<string name="analog_style_scallop">Scallop</string>
<!-- M11: stopwatch widget -->
<string name="widget_stopwatch_description">Stopwatch</string>
<string name="widget_stopwatch_label">Stopwatch</string>
<string name="widget_stopwatch_laps">Laps: %1$d</string>
<string name="widget_stopwatch_config_title">Stopwatch</string>
<string name="stopwatch_style_card">Card</string>
<string name="stopwatch_style_circle">Circle</string>
<!-- M11: quick-timer widget -->
<string name="widget_quick_timer_description">Quick timer</string>
<string name="widget_quick_timer_label">Quick timer</string>
<string name="widget_quick_timer_description">Starts a timer with one tap</string>
<string name="widget_quick_timer_config_title">Quick timer</string>
<string name="widget_quick_timer_start">Start %1$s</string>
</resources>
@@ -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" />
@@ -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" />
@@ -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" />
@@ -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" />
@@ -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" />
@@ -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) {
@@ -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)
}
}
}