feat(widgets): M3 Expressive widget lineup — analog, digital, stopwatch, quick timer

Six widgets, each with minimal settings, styled with M3 Expressive shapes and
Material You colours, and sized proportionally to the placed widget
(SizeMode.Exact + LocalSize):

- Analog clock (new): nine faces on MaterialShapes dials (Round, Numeral,
  Day, Cookie, Sunny, Scallop, Clover, Square, Petal) with per-face hands,
  a seconds dot and Google-Clock-style orbiting date on some faces, and a
  day/date pill on others. Style picker previews inflate the real views.
- Digital clock: rebuilt as RemoteViews faces (Classic, Thin, Bold, Pill,
  Stacked) shared by the widget and its config previews; world clocks are
  opt-in and only on the card faces.
- Next alarm and Timers: shape badges, icon buttons, proportional readouts;
  a lone timer on a tall widget gets a stacked layout.
- Stopwatch (new): large readout with play/pause pill and lap/reset.
- Quick timer (new): a Scallop that is a timer of its own — tap to start a
  transient timer of its configured duration, tap again to reset/stop.

Colours resolve in the launcher (widget_* palette resources, GlanceTheme's
dynamic colours) so widgets follow day/night and the wallpaper. Data widgets
collect their flows inside the composition so a live Glance session never
shows stale state, chronometer bases use elapsedRealtime, and config
activities close cleanly on an invalid widget id.
This commit is contained in:
2026-10-02 18:01:11 +02:00
parent 5d03c654bf
commit 82fff19d3d
103 changed files with 3793 additions and 280 deletions
+1
View File
@@ -198,6 +198,7 @@ dependencies {
implementation("de.jeanlucmakiola.floret:core-di")
implementation("de.jeanlucmakiola.floret:identity")
implementation("de.jeanlucmakiola.floret:components")
implementation("de.jeanlucmakiola.floret:glance")
debugImplementation(libs.androidx.ui.tooling)
debugImplementation(libs.androidx.ui.test.manifest)
+86 -9
View File
@@ -233,15 +233,59 @@
android:value="true" />
</service>
<!-- The three M10 home-screen widgets (docs/PLAN.md §9). Each is a
GlanceAppWidgetReceiver, and each must be exported with only the
APPWIDGET_UPDATE filter — that is how the launcher delivers a
widget update, and the platform requires exported="true" for a
widget provider to be pickable at all. No permission guards it,
same as any other app's widget: APPWIDGET_UPDATE is a
platform-delivered broadcast, never a door another app can open by
naming the action itself. updatePeriodMillis is 0 in every
provider-info XML — WidgetSync is the one refresh trigger. -->
<!-- The digital clock widget's per-instance style/date/world-clocks
picker (M11). Exported with only the APPWIDGET_CONFIGURE filter,
as the launcher requires to open it on placement and on "Edit
widget"; carries no extra data of its own beyond the app widget
id the launcher passes in. -->
<activity
android:name=".ui.widget.config.ClockWidgetConfigActivity"
android:exported="true"
android:theme="@style/Theme.Clockula">
<intent-filter>
<action android:name="android.appwidget.action.APPWIDGET_CONFIGURE" />
</intent-filter>
</activity>
<!-- The analog clock widget's per-instance style picker (M11): the
same APPWIDGET_CONFIGURE contract as the digital widget's above. -->
<activity
android:name=".ui.widget.config.AnalogClockConfigActivity"
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. -->
<activity
android:name=".ui.widget.config.QuickTimerConfigActivity"
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 one tap: creates and starts a timer at
its configured duration. A broadcast, not a service start or an
activity, for the same reason as `TimerActionReceiver` (M11 D16). -->
<receiver
android:name=".timer.receiver.QuickTimerReceiver"
android:exported="false" />
<!-- The six home-screen widgets. Each is a GlanceAppWidgetReceiver,
and each must be exported with only the APPWIDGET_UPDATE filter —
that is how the launcher delivers a widget update, and the
platform requires exported="true" for a widget provider to be
pickable at all. No permission guards it, same as any other
app's widget: APPWIDGET_UPDATE is a platform-delivered broadcast,
never a door another app can open by naming the action itself.
updatePeriodMillis is 0 in every provider-info XML — WidgetSync
is the one refresh trigger (the quick-timer widget has no live
state, so it is refreshed only by its own config save). -->
<receiver
android:name=".widget.NextAlarmWidgetReceiver"
android:exported="true">
@@ -274,6 +318,39 @@
android:name="android.appwidget.provider"
android:resource="@xml/widget_clock_info" />
</receiver>
<receiver
android:name=".widget.AnalogClockWidgetReceiver"
android:exported="true">
<intent-filter>
<action android:name="android.appwidget.action.APPWIDGET_UPDATE" />
</intent-filter>
<meta-data
android:name="android.appwidget.provider"
android:resource="@xml/widget_analog_clock_info" />
</receiver>
<receiver
android:name=".widget.StopwatchWidgetReceiver"
android:exported="true">
<intent-filter>
<action android:name="android.appwidget.action.APPWIDGET_UPDATE" />
</intent-filter>
<meta-data
android:name="android.appwidget.provider"
android:resource="@xml/widget_stopwatch_info" />
</receiver>
<receiver
android:name=".widget.QuickTimerWidgetReceiver"
android:exported="true">
<intent-filter>
<action android:name="android.appwidget.action.APPWIDGET_UPDATE" />
</intent-filter>
<meta-data
android:name="android.appwidget.provider"
android:resource="@xml/widget_quick_timer_info" />
</receiver>
</application>
</manifest>
@@ -0,0 +1,13 @@
package de.jeanlucmakiola.clockula.domain.widget
/**
* The analog faces an [de.jeanlucmakiola.clockula.widget.AnalogClockWidget]
* instance can render: a dial shape (one of the M3 Expressive `MaterialShapes`,
* as `res/drawable/widget_analog_dial_*`) plus what sits on it. See
* `ui/widget/AnalogClockWidgetContent.kt` for the mapping.
*/
enum class AnalogClockStyle { ROUND, NUMERAL, BADGE, COOKIE, SUNNY, SCALLOP, CLOVER, SQUARE, PETAL }
/** Anything unreadable, or a name this enum no longer has, falls back to [AnalogClockStyle.ROUND]. */
fun parseAnalogClockStyle(raw: String?): AnalogClockStyle =
raw?.let { runCatching { AnalogClockStyle.valueOf(it) }.getOrNull() } ?: AnalogClockStyle.ROUND
@@ -0,0 +1,29 @@
package de.jeanlucmakiola.clockula.domain.widget
/**
* The digital faces a [de.jeanlucmakiola.clockula.widget.ClockWidget]
* instance can render, in picker order. [REGULAR] is the default, so an
* already-placed widget (no stored style) and a "skip configuration"
* placement both render the same plain face. Stored by [name], so the
* original four keep theirs even where their labels changed (Classic, Thin).
*/
enum class DigitalClockStyle { REGULAR, LIGHT, BOLD, PILL, STACKED }
/** Whether [this] face has room for world-clock rows at all — only the card faces do. */
val DigitalClockStyle.showsWorldClocks: Boolean
get() = this == DigitalClockStyle.REGULAR || this == DigitalClockStyle.LIGHT || this == DigitalClockStyle.BOLD
/** Anything unreadable, or a name this enum no longer has, falls back to [DigitalClockStyle.REGULAR]. */
fun parseDigitalClockStyle(raw: String?): DigitalClockStyle =
raw?.let { runCatching { DigitalClockStyle.valueOf(it) }.getOrNull() } ?: DigitalClockStyle.REGULAR
/**
* One digital-clock widget instance's own picks, stored per-instance
* (`widget/ClockWidgetState.kt`) — never app-wide. [showDate] defaults to
* true, [showWorldClocks] to false: world clocks are opt-in.
*/
data class DigitalClockSettings(
val style: DigitalClockStyle = DigitalClockStyle.REGULAR,
val showDate: Boolean = true,
val showWorldClocks: Boolean = false,
)
@@ -0,0 +1,42 @@
package de.jeanlucmakiola.clockula.domain.widget
import de.jeanlucmakiola.clockula.domain.Timer
import de.jeanlucmakiola.clockula.domain.timer.TimerPresets
import kotlin.time.Duration
import kotlin.time.Duration.Companion.milliseconds
import kotlin.time.Duration.Companion.minutes
import kotlin.time.Instant
/** One quick-timer widget instance's own pick: the duration it starts on tap. */
data class QuickTimerWidgetSettings(val duration: Duration = DEFAULT) {
companion object {
val DEFAULT: Duration = 5.minutes
}
}
/** Anything unreadable, or out of [TimerPresets]' own sane range, falls back to [QuickTimerWidgetSettings.DEFAULT]. */
fun parseQuickTimerDuration(raw: Long?): Duration {
val duration = raw?.milliseconds
return duration?.takeIf { it in TimerPresets.MIN..TimerPresets.MAX } ?: QuickTimerWidgetSettings.DEFAULT
}
/**
* What the quick-timer widget shows: its configured [duration], and the
* timer it started, while that one is still [active] (running, paused or
* expired). `null` means the widget is at rest, offering to start.
*/
data class QuickTimerWidgetModel(val duration: Duration, val active: TimerWidgetItem?)
object QuickTimerWidgetPresenter {
/**
* [timerId] is the timer this widget instance last started. It is
* transient (deleted on reset/stop), so once it is gone — or back to
* idle — the widget is simply at rest again.
*/
fun present(duration: Duration, timerId: Long?, timers: List<Timer>, elapsedRealtime: Duration, wallClock: Instant): QuickTimerWidgetModel {
val own = timers.filter { it.id == timerId }
val active = TimerWidgetPresenter.present(own, elapsedRealtime, wallClock).items.firstOrNull()
return QuickTimerWidgetModel(duration, active)
}
}
@@ -0,0 +1,22 @@
package de.jeanlucmakiola.clockula.domain.widget
import de.jeanlucmakiola.clockula.domain.StopwatchRun
import de.jeanlucmakiola.clockula.domain.StopwatchState
import de.jeanlucmakiola.clockula.domain.stopwatch.StopwatchReadings
import kotlin.time.Duration
/** What the stopwatch widget shows: the live [mode] and [elapsed], plus how many laps are banked. */
data class StopwatchWidgetModel(
val mode: StopwatchState,
val elapsed: Duration,
val lapCount: Int,
)
object StopwatchWidgetPresenter {
/** Pure; mirrors `StopwatchReadings.of` — the same "now" every other stopwatch surface reads. */
fun present(run: StopwatchRun, elapsedRealtime: Duration, lapCount: Int): StopwatchWidgetModel {
val reading = StopwatchReadings.of(run, elapsedRealtime)
return StopwatchWidgetModel(mode = reading.mode, elapsed = reading.elapsed, lapCount = lapCount)
}
}
@@ -0,0 +1,12 @@
package de.jeanlucmakiola.clockula.timer
/** The quick-timer widget's start action: a fresh, transient timer at its configured duration. */
object QuickTimerIntents {
private const val PREFIX = "de.jeanlucmakiola.clockula."
const val ACTION_START: String = PREFIX + "action.QUICK_TIMER_START"
const val EXTRA_DURATION_MILLIS: String = PREFIX + "extra.QUICK_TIMER_DURATION_MILLIS"
/** The widget instance that asked, so the timer it started can be shown on it. */
const val EXTRA_APP_WIDGET_ID: String = PREFIX + "extra.QUICK_TIMER_APP_WIDGET_ID"
}
@@ -0,0 +1,66 @@
package de.jeanlucmakiola.clockula.timer.receiver
import android.appwidget.AppWidgetManager
import android.content.Context
import android.content.Intent
import androidx.glance.appwidget.GlanceAppWidgetManager
import androidx.glance.appwidget.state.updateAppWidgetState
import dagger.hilt.android.AndroidEntryPoint
import de.jeanlucmakiola.clockula.alarm.receiver.HiltBroadcastReceiver
import de.jeanlucmakiola.clockula.data.timers.TimerRepository
import de.jeanlucmakiola.clockula.domain.TimerDraft
import de.jeanlucmakiola.clockula.timer.QuickTimerIntents
import de.jeanlucmakiola.clockula.timer.TimerEngine
import de.jeanlucmakiola.clockula.widget.QuickTimerWidget
import de.jeanlucmakiola.clockula.widget.WIDGET_QUICK_TIMER_ID_KEY
import de.jeanlucmakiola.floret.di.ApplicationScope
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
import javax.inject.Inject
import kotlin.time.Duration.Companion.milliseconds
/**
* The quick-timer widget's one tap: creates a fresh timer at the widget's
* configured duration and starts it immediately — the same two-step path
* `ui/timers/TimersViewModel.kt`'s quick-start button already uses. A
* broadcast, not an activity, so tapping the widget never has to open the app
* (the same reasoning as `TimerActionReceiver`/`StopwatchActionReceiver`, D16).
*/
@AndroidEntryPoint
class QuickTimerReceiver : HiltBroadcastReceiver() {
@Inject lateinit var timers: TimerRepository
@Inject lateinit var engine: TimerEngine
@Inject
@ApplicationScope
lateinit var scope: CoroutineScope
override fun onReceive(context: Context, intent: Intent) {
super.onReceive(context, intent)
if (intent.action != QuickTimerIntents.ACTION_START) return
val durationMillis = intent.getLongExtra(QuickTimerIntents.EXTRA_DURATION_MILLIS, 0L)
if (durationMillis <= 0L) return
val appWidgetId = intent.getIntExtra(QuickTimerIntents.EXTRA_APP_WIDGET_ID, AppWidgetManager.INVALID_APPWIDGET_ID)
val pendingResult = goAsync()
scope.launch {
try {
// Transient: the widget's own reset/stop deletes it rather
// than leaving a stray idle timer in the Timers list.
val id = timers.create(TimerDraft(duration = durationMillis.milliseconds, deleteAfterUse = true))
engine.start(id)
// Remember which timer this widget started, so it shows that
// one's countdown and controls (`ui/widget/QuickTimerWidgetContent.kt`).
val glanceId = runCatching { GlanceAppWidgetManager(context).getGlanceIdBy(appWidgetId) }.getOrNull()
if (glanceId != null) {
updateAppWidgetState(context, glanceId) { prefs -> prefs[WIDGET_QUICK_TIMER_ID_KEY] = id }
QuickTimerWidget().update(context, glanceId)
}
} finally {
pendingResult.finish()
}
}
}
}
@@ -0,0 +1,246 @@
package de.jeanlucmakiola.clockula.ui.widget
import android.content.Context
import android.content.Intent
import android.graphics.Bitmap
import android.graphics.Canvas
import android.graphics.Paint
import android.graphics.Typeface
import android.graphics.drawable.Icon
import android.os.Build
import androidx.annotation.DrawableRes
import android.text.format.DateFormat
import android.util.TypedValue
import android.view.View
import android.widget.RemoteViews
import androidx.compose.runtime.Composable
import androidx.datastore.preferences.core.Preferences
import androidx.glance.GlanceModifier
import androidx.glance.LocalContext
import androidx.glance.LocalSize
import androidx.glance.action.clickable
import androidx.glance.appwidget.AndroidRemoteViews
import androidx.glance.appwidget.action.actionStartActivity
import androidx.glance.currentState
import androidx.glance.layout.Alignment
import androidx.glance.layout.Box
import androidx.glance.layout.fillMaxSize
import androidx.glance.layout.size
import de.jeanlucmakiola.clockula.MainActivity
import de.jeanlucmakiola.clockula.R
import de.jeanlucmakiola.clockula.domain.widget.AnalogClockStyle
import de.jeanlucmakiola.clockula.domain.widget.parseAnalogClockStyle
import de.jeanlucmakiola.clockula.ui.shell.ShellNavigation
import de.jeanlucmakiola.clockula.widget.WIDGET_ANALOG_STYLE_KEY
/** Reads this instance's own [AnalogClockStyle] reactively, then renders [AnalogClockWidgetContent]. */
@Composable
fun AnalogClockWidgetRoot() {
val prefs = currentState<Preferences>()
AnalogClockWidgetContent(style = parseAnalogClockStyle(prefs[WIDGET_ANALOG_STYLE_KEY]))
}
/**
* The analog widget's content: [style]'s dial/overlay ([analogClockViews])
* floating directly on the home screen, not on a card, the way a platform
* clock widget looks. Its colours are the analog palette resources
* (`res/values/colors.xml` and its night/v31 variants), so it follows the launcher's day/night and, on
* API 31+, the wallpaper by itself.
*/
@Composable
fun AnalogClockWidgetContent(style: AnalogClockStyle) {
val context = LocalContext.current
Box(
modifier = GlanceModifier.fillMaxSize().clickable(actionStartActivity(showWorldClockIntent(context))),
contentAlignment = Alignment.Center,
) {
AnalogFace(style)
}
}
/**
* The `AnalogClock` layouts fill whatever they're given (`match_parent`), so
* *this* is what actually sizes the dial: the smaller of the widget's live
* placed width/height, read from [LocalSize].
*/
@Composable
private fun AnalogFace(style: AnalogClockStyle) {
val context = LocalContext.current
val size = LocalSize.current
val side = minOf(size.width, size.height)
Box(modifier = GlanceModifier.size(side)) {
AndroidRemoteViews(
remoteViews = analogClockViews(context, style, side.value),
modifier = GlanceModifier.fillMaxSize(),
)
}
}
/**
* What sits on an analog dial besides the hands — picks the layout. [DAY] is
* the 12/9/6 numerals plus a day pill at three; [DATE_PILL] is that layout
* with the numerals hidden and the full date ("Fri 2") in the pill.
*/
private enum class AnalogOverlay { NONE, NUMERALS, DAY, DATE_PILL }
private fun AnalogClockStyle.dial(): Int = when (this) {
AnalogClockStyle.ROUND, AnalogClockStyle.NUMERAL, AnalogClockStyle.BADGE -> R.drawable.widget_analog_dial_round
AnalogClockStyle.COOKIE -> R.drawable.widget_analog_dial_cookie
AnalogClockStyle.CLOVER -> R.drawable.widget_analog_dial_clover
AnalogClockStyle.PETAL -> R.drawable.widget_analog_dial_petal
AnalogClockStyle.SUNNY -> R.drawable.widget_analog_dial_sunny
AnalogClockStyle.SQUARE -> R.drawable.widget_analog_dial_square
AnalogClockStyle.SCALLOP -> R.drawable.widget_analog_dial_scallop
}
/** The hour/minute hand drawables a style wears (`res/drawable/widget_analog_{hour,minute}_*`). */
private enum class AnalogHands(@DrawableRes val hour: Int, @DrawableRes val minute: Int) {
CAPSULE(R.drawable.widget_analog_hour_hand, R.drawable.widget_analog_minute_hand),
BOLD(R.drawable.widget_analog_hour_bold, R.drawable.widget_analog_minute_bold),
SLIM(R.drawable.widget_analog_hour_slim, R.drawable.widget_analog_minute_slim),
LOLLIPOP(R.drawable.widget_analog_hour_lollipop, R.drawable.widget_analog_minute_lollipop),
}
private fun AnalogClockStyle.hands(): AnalogHands = when (this) {
AnalogClockStyle.ROUND, AnalogClockStyle.BADGE, AnalogClockStyle.SCALLOP -> AnalogHands.CAPSULE
AnalogClockStyle.COOKIE, AnalogClockStyle.SQUARE -> AnalogHands.BOLD
AnalogClockStyle.NUMERAL, AnalogClockStyle.CLOVER -> AnalogHands.SLIM
AnalogClockStyle.SUNNY, AnalogClockStyle.PETAL -> AnalogHands.LOLLIPOP
}
/** What a style's seconds hand is, if it has one at all. */
private sealed interface AnalogSeconds {
data object None : AnalogSeconds
/** A dot alone; the drawable fixes its size and orbit. */
data class Dot(@DrawableRes val drawable: Int) : AnalogSeconds
/** Google Clock's: a dot plus the date opposite it. */
data class DotAndDate(@DrawableRes val dot: Int) : AnalogSeconds
}
private fun AnalogClockStyle.seconds(): AnalogSeconds = when (this) {
AnalogClockStyle.ROUND, AnalogClockStyle.NUMERAL, AnalogClockStyle.BADGE,
AnalogClockStyle.CLOVER -> AnalogSeconds.None
AnalogClockStyle.SUNNY -> AnalogSeconds.Dot(R.drawable.widget_analog_second_dot)
AnalogClockStyle.SQUARE -> AnalogSeconds.Dot(R.drawable.widget_analog_second_dot_bold)
AnalogClockStyle.PETAL -> AnalogSeconds.Dot(R.drawable.widget_analog_second_dot_petal)
AnalogClockStyle.COOKIE -> AnalogSeconds.DotAndDate(R.drawable.widget_analog_second_dot_dated)
AnalogClockStyle.SCALLOP -> AnalogSeconds.DotAndDate(R.drawable.widget_analog_second_dot_scallop)
}
private fun AnalogClockStyle.overlay(): AnalogOverlay = when (this) {
AnalogClockStyle.NUMERAL, AnalogClockStyle.CLOVER -> AnalogOverlay.NUMERALS
AnalogClockStyle.BADGE -> AnalogOverlay.DAY
AnalogClockStyle.PETAL -> AnalogOverlay.DATE_PILL
AnalogClockStyle.ROUND, AnalogClockStyle.COOKIE, AnalogClockStyle.SUNNY,
AnalogClockStyle.SCALLOP, AnalogClockStyle.SQUARE -> AnalogOverlay.NONE
}
/**
* The analog face for [style] at a [sideDp]-square size. The single builder
* behind both the placed widget and the config picker's previews (which
* inflate these same views in-app), so a preview is the real widget.
* Numerals, badge and their insets are fractions of [sideDp], so they grow
* and shrink with the dial instead of staying at a fixed sp/dp.
*/
internal fun analogClockViews(context: Context, style: AnalogClockStyle, sideDp: Float): RemoteViews {
val overlay = style.overlay()
val layoutRes = when (overlay) {
AnalogOverlay.NONE -> R.layout.widget_analog_plain
AnalogOverlay.NUMERALS -> R.layout.widget_analog_numeral
AnalogOverlay.DAY, AnalogOverlay.DATE_PILL -> R.layout.widget_analog_badge
}
val density = context.resources.displayMetrics.density
fun frac(f: Float): Int = (sideDp * f * density).toInt()
return RemoteViews(context.packageName, layoutRes).apply {
setImageViewResource(R.id.widget_analog_dial, style.dial())
// `AnalogClock`'s hand setters (and its seconds hand at all) only
// exist from API 31; before that every style keeps the layout's
// capsule hands and no seconds.
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
val hands = style.hands()
setIcon(R.id.widget_clock_analog, "setHourHand", Icon.createWithResource(context, hands.hour))
setIcon(R.id.widget_clock_analog, "setMinuteHand", Icon.createWithResource(context, hands.minute))
when (val seconds = style.seconds()) {
AnalogSeconds.None -> Unit
is AnalogSeconds.Dot ->
setIcon(R.id.widget_clock_analog, "setSecondHand", Icon.createWithResource(context, seconds.drawable))
is AnalogSeconds.DotAndDate -> {
setIcon(R.id.widget_clock_analog, "setSecondHand", Icon.createWithResource(context, seconds.dot))
// Only the plain layout has the date clock, and only
// plain faces are dated.
setViewVisibility(R.id.widget_clock_analog_date, View.VISIBLE)
setIcon(R.id.widget_clock_analog_date, "setSecondHand", Icon.createWithBitmap(dateHand(context)))
setColorStateList(R.id.widget_clock_analog_date, "setSecondHandTintList", R.color.widget_analog_date)
}
}
}
if (overlay == AnalogOverlay.NONE) return@apply
val inset = frac(0.07f)
val numerals = if (overlay == AnalogOverlay.DATE_PILL) emptyList() else buildList {
add(R.id.widget_analog_numeral_12 to intArrayOf(0, inset, 0, 0))
add(R.id.widget_analog_numeral_6 to intArrayOf(0, 0, 0, inset))
add(R.id.widget_analog_numeral_9 to intArrayOf(inset, 0, 0, 0))
if (overlay == AnalogOverlay.NUMERALS) add(R.id.widget_analog_numeral_3 to intArrayOf(0, 0, inset, 0))
}
numerals.forEach { (id, p) ->
setTextViewTextSize(id, TypedValue.COMPLEX_UNIT_DIP, sideDp * 0.15f)
setViewPadding(id, p[0], p[1], p[2], p[3])
}
if (overlay == AnalogOverlay.DATE_PILL) {
listOf(R.id.widget_analog_numeral_12, R.id.widget_analog_numeral_6, R.id.widget_analog_numeral_9)
.forEach { setViewVisibility(it, View.GONE) }
}
if (overlay == AnalogOverlay.DAY || overlay == AnalogOverlay.DATE_PILL) {
val skeleton = if (overlay == AnalogOverlay.DAY) "EEE" else "EEEd"
val pattern = DateFormat.getBestDateTimePattern(currentLocale(context), skeleton)
setCharSequence(R.id.widget_analog_badge_day, "setFormat12Hour", pattern)
setCharSequence(R.id.widget_analog_badge_day, "setFormat24Hour", pattern)
setTextViewTextSize(R.id.widget_analog_badge_day, TypedValue.COMPLEX_UNIT_DIP, sideDp * 0.075f)
setViewPadding(R.id.widget_analog_badge_day, frac(0.045f), frac(0.02f), frac(0.045f), frac(0.02f))
// Petal's rim dips in between its lobes, so its pill sits further in.
val edgeInset = if (overlay == AnalogOverlay.DAY) 0.06f else 0.1f
setViewPadding(R.id.widget_analog_badge_frame, 0, 0, frac(edgeInset), 0)
}
}
}
/**
* The date half of Google Clock's seconds hand: today's date ("Fri 2")
* opposite the seconds dot, on the layout's separate date clock. Text cannot
* live in a vector drawable, so this hand is a bitmap — an alpha mask, tinted by
* `setSecondHandTintList` in the launcher so it still follows day/night.
* The date goes stale at midnight until the next update; the provider's 30
* minute `updatePeriodMillis` bounds that.
*/
private fun dateHand(context: Context): Bitmap {
val px = 360
val unit = px / 100f
val bitmap = Bitmap.createBitmap(px, px, Bitmap.Config.ALPHA_8)
// 360px at 120dpi reads as 480dp — the intrinsic size every other hand
// and the dial share, so `AnalogClock` scales them all alike. Kept to
// 360px (and alpha-only) so the bitmap stays small in the RemoteViews.
bitmap.density = 120
val canvas = Canvas(bitmap)
val paint = Paint(Paint.ANTI_ALIAS_FLAG)
paint.textSize = 9.5f * unit
paint.typeface = Typeface.create(Typeface.DEFAULT, 600, false)
paint.textAlign = Paint.Align.CENTER
val pattern = DateFormat.getBestDateTimePattern(currentLocale(context), "EEEd")
val date = DateFormat.format(pattern, System.currentTimeMillis()).toString()
val metrics = paint.fontMetrics
// Centred at radius 33, so the text stays inside the dated faces'
// notches (41+) even at its corners.
val dateY = 83 * unit
// Drawn upside down here, below the pivot, so it reads upright when the
// hand carries it over the top of the dial — where it is read most.
canvas.rotate(180f, 50 * unit, dateY)
canvas.drawText(date, 50 * unit, dateY - (metrics.ascent + metrics.descent) / 2, paint)
return bitmap
}
private fun showWorldClockIntent(context: Context): Intent =
Intent(context, MainActivity::class.java)
.setAction(ShellNavigation.ACTION_SHOW_WORLD_CLOCK)
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
@@ -2,88 +2,86 @@ package de.jeanlucmakiola.clockula.ui.widget
import android.content.Context
import android.content.Intent
import android.widget.RemoteViews
import androidx.compose.runtime.Composable
import androidx.compose.ui.unit.Dp
import androidx.compose.runtime.getValue
import androidx.compose.runtime.produceState
import androidx.datastore.preferences.core.Preferences
import androidx.glance.GlanceModifier
import androidx.glance.GlanceTheme
import androidx.glance.LocalContext
import androidx.glance.LocalSize
import androidx.glance.text.Text
import androidx.glance.action.clickable
import androidx.glance.appwidget.AndroidRemoteViews
import androidx.glance.appwidget.action.actionStartActivity
import androidx.glance.appwidget.cornerRadius
import androidx.glance.background
import androidx.glance.layout.Alignment
import androidx.glance.currentState
import androidx.glance.layout.Box
import androidx.glance.layout.Column
import androidx.glance.layout.fillMaxSize
import androidx.glance.layout.padding
import androidx.glance.text.TextStyle
import de.jeanlucmakiola.clockula.MainActivity
import de.jeanlucmakiola.clockula.R
import de.jeanlucmakiola.clockula.data.worldclocks.WorldClockRepository
import de.jeanlucmakiola.clockula.data.zones.ZoneNames
import de.jeanlucmakiola.clockula.domain.widget.ClockWidgetPresenter
import de.jeanlucmakiola.clockula.domain.widget.DigitalClockSettings
import de.jeanlucmakiola.clockula.ui.shell.ShellNavigation
import de.jeanlucmakiola.clockula.widget.WIDGET_DIGITAL_STYLE_KEY
import de.jeanlucmakiola.clockula.widget.WIDGET_SHOW_DATE_KEY
import de.jeanlucmakiola.clockula.widget.WIDGET_SHOW_WORLD_CLOCKS_KEY
import de.jeanlucmakiola.clockula.widget.di.WidgetEntryPoint
import de.jeanlucmakiola.clockula.widget.parseDigitalClockSettings
import kotlinx.coroutines.flow.first
/** One resolved world clock for the clock widget: the zone id a `TextClock` ticks against, with its already-resolved city name. */
/** One resolved world clock for the digital widget: the zone id a `TextClock` ticks against, with its already-resolved city name. */
data class ClockWidgetCity(val zoneId: String, val cityName: String)
/** The world clocks the digital widget shows, resolved to city names — shared by the widget and its config preview. */
internal suspend fun widgetCities(worldClocks: WorldClockRepository, zoneNames: ZoneNames): List<ClockWidgetCity> =
ClockWidgetPresenter.present(worldClocks.worldClocks().first()).map { clock ->
ClockWidgetCity(
zoneId = clock.zoneId,
cityName = clock.label?.takeIf(String::isNotBlank) ?: zoneNames.cityOf(clock.zoneId) ?: clock.zoneId,
)
}
/**
* The clock widget's content. Responsive by size (`LocalSize.current`), read
* here rather than via `SizeMode.Responsive` so the `GlanceAppWidget` subclass
* in `widget/` never needs a Compose-namespace `DpSize` import
* (`ArchitectureRulesTest`'s "Compose stays inside ui/" rule).
* Reads this widget instance's own state — reactively, so a config-screen
* save that lands on a still-live Glance session (see `widget/ClockWidget.kt`)
* recomposes with the new values instead of the ones the session started
* with — then renders [ClockWidgetContent].
*/
@Composable
fun ClockWidgetContent(cities: List<ClockWidgetCity>) {
fun ClockWidgetRoot(entryPoint: WidgetEntryPoint) {
val prefs = currentState<Preferences>()
val settings = parseDigitalClockSettings(
style = prefs[WIDGET_DIGITAL_STYLE_KEY],
showDate = prefs[WIDGET_SHOW_DATE_KEY],
showWorldClocks = prefs[WIDGET_SHOW_WORLD_CLOCKS_KEY],
)
val showWorldClocks = settings.showWorldClocks
val cities by produceState(initialValue = emptyList<ClockWidgetCity>(), showWorldClocks) {
value = if (showWorldClocks) widgetCities(entryPoint.worldClockRepository(), entryPoint.zoneNames()) else emptyList()
}
ClockWidgetContent(cities = cities, settings = settings)
}
/**
* The digital widget's content: [settings]' face ([digitalClockViews]) at
* the widget's live size. Like the analog widget, the face draws its own
* container (a card, pills, or a shape) and takes its colours from the
* `widget_*` palette resources, so it follows the launcher's day/night and
* the wallpaper by itself.
*/
@Composable
fun ClockWidgetContent(cities: List<ClockWidgetCity>, settings: DigitalClockSettings) {
val context = LocalContext.current
val size = LocalSize.current
val showDate = size.width >= MEDIUM_WIDTH
val showCities = size.width >= LARGE_WIDTH && cities.isNotEmpty()
GlanceTheme(WidgetDefaults.Colors) {
Box(
modifier = GlanceModifier
.fillMaxSize()
.background(GlanceTheme.colors.widgetBackground)
.cornerRadius(WidgetDefaults.CornerRadius)
.padding(WidgetDefaults.ContentPadding)
.clickable(actionStartActivity(showWorldClockIntent(context))),
contentAlignment = Alignment.CenterStart,
) {
Column {
TextClockRow(context, zoneId = null)
if (showDate) {
Text(todayLabel(), style = TextStyle(color = GlanceTheme.colors.onSurfaceVariant, fontSize = WidgetDefaults.BodyTextSize))
}
if (showCities) {
cities.forEach { city ->
Text(city.cityName, style = TextStyle(color = GlanceTheme.colors.onSurfaceVariant, fontSize = WidgetDefaults.CaptionTextSize))
TextClockRow(context, zoneId = city.zoneId)
}
}
}
}
Box(modifier = GlanceModifier.fillMaxSize().clickable(actionStartActivity(showWorldClockIntent(context)))) {
AndroidRemoteViews(
remoteViews = digitalClockViews(context, settings, cities, size.width.value, size.height.value),
modifier = GlanceModifier.fillMaxSize(),
)
}
}
/** [zoneId] null means the device's own zone — a plain `TextClock` with no timezone override. */
@Composable
private fun TextClockRow(context: Context, zoneId: String?) {
val views = RemoteViews(context.packageName, R.layout.widget_text_clock).apply {
if (zoneId != null) setString(R.id.widget_text_clock, "setTimeZone", zoneId)
}
AndroidRemoteViews(remoteViews = views)
}
/** A plain `java.time` date string — no `android.icu`, this is not a localised zone name (`data/zones/` owns that). */
private fun todayLabel(): String =
java.time.LocalDate.now().format(java.time.format.DateTimeFormatter.ofPattern("EEE, MMM d"))
private fun showWorldClockIntent(context: Context): Intent =
Intent(context, MainActivity::class.java)
.setAction(ShellNavigation.ACTION_SHOW_WORLD_CLOCK)
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
private val MEDIUM_WIDTH = Dp(180f)
private val LARGE_WIDTH = Dp(250f)
@@ -0,0 +1,158 @@
package de.jeanlucmakiola.clockula.ui.widget
import android.content.Context
import android.text.format.DateFormat
import android.util.TypedValue
import android.view.View
import android.widget.RemoteViews
import de.jeanlucmakiola.clockula.R
import de.jeanlucmakiola.clockula.domain.widget.DigitalClockSettings
import de.jeanlucmakiola.clockula.domain.widget.DigitalClockStyle
import de.jeanlucmakiola.clockula.domain.widget.showsWorldClocks
/** Below this the time is too small to share the card with world-clock rows, so they drop first. */
private const val MIN_TIME_WITH_CITIES = 30f
/** Below this the time drops the date line too. */
private const val MIN_TIME_WITH_DATE = 22f
/**
* The digital face for [settings] at [widthDp] x [heightDp]. The single
* builder behind both the placed widget and the config picker's previews
* (which inflate these same views in-app), so a preview is the real widget —
* as `analogClockViews` is for the analog one. Every text size and spacing
* is a fraction of the size, so the face fills the widget at any size
* instead of sitting at a fixed sp in the middle of it; on a small widget,
* world clocks and then the date drop out rather than squeezing the time.
*
* The time and date are `TextClock`s ticking in the launcher; the time drops
* the 12-hour AM/PM marker, as the platform's own clock widgets do.
*/
internal fun digitalClockViews(
context: Context,
settings: DigitalClockSettings,
cities: List<ClockWidgetCity>,
widthDp: Float,
heightDp: Float,
): RemoteViews = when (settings.style) {
DigitalClockStyle.REGULAR, DigitalClockStyle.LIGHT, DigitalClockStyle.BOLD ->
cardViews(context, settings, cities, widthDp, heightDp)
DigitalClockStyle.PILL -> pillViews(context, settings.showDate, widthDp, heightDp)
DigitalClockStyle.STACKED -> stackedViews(context, settings.showDate, minOf(widthDp, heightDp))
}
/** The card faces: time, date, then up to three world-clock rows. */
private fun cardViews(
context: Context,
settings: DigitalClockSettings,
cities: List<ClockWidgetCity>,
w: Float,
h: Float,
): RemoteViews {
val (layout, cityLayout, emsWide) = when (settings.style) {
DigitalClockStyle.LIGHT -> Triple(R.layout.widget_digital_thin, R.layout.widget_digital_city, 2.6f)
DigitalClockStyle.BOLD -> Triple(R.layout.widget_digital_bold, R.layout.widget_digital_city_on_primary, 2.95f)
else -> Triple(R.layout.widget_digital_classic, R.layout.widget_digital_city, 2.75f)
}
val pad = (minOf(w, h) * 0.1f).coerceIn(10f, 22f)
val innerW = w - 2 * pad
val innerH = h - 2 * pad
// Heights in ems of the time: its own line, the date's (0.3em text), and
// each city row's (0.3em text) after a small gap.
fun timeSize(cityCount: Int, date: Boolean): Float {
val ems = 1.12f + (if (date) 0.42f else 0f) + (if (cityCount > 0) 0.12f + cityCount * 0.44f else 0f)
return minOf(innerW / emsWide, innerH / ems)
}
var cityCount = if (settings.showWorldClocks && settings.style.showsWorldClocks) cities.size else 0
var showDate = settings.showDate
while (cityCount > 0 && timeSize(cityCount, showDate) < MIN_TIME_WITH_CITIES) cityCount--
if (showDate && timeSize(cityCount, true) < MIN_TIME_WITH_DATE) showDate = false
val time = timeSize(cityCount, showDate)
val small = (time * 0.3f).coerceAtLeast(10f)
val px = pxOf(context)
return RemoteViews(context.packageName, layout).apply {
setViewPadding(R.id.widget_digital_root, px(pad), px(pad), px(pad), px(pad))
setTime(context, R.id.widget_digital_time, time)
setDate(context, R.id.widget_digital_date, showDate, small, "EEEMMMd")
if (cityCount > 0) {
setViewVisibility(R.id.widget_digital_cities, View.VISIBLE)
setViewPadding(R.id.widget_digital_cities, 0, px(time * 0.12f), 0, 0)
val cityPattern = if (DateFormat.is24HourFormat(context)) "HH:mm" else "h:mm a"
cities.take(cityCount).forEachIndexed { index, city ->
val row = RemoteViews(context.packageName, cityLayout).apply {
setTextViewText(R.id.widget_digital_city_name, city.cityName)
setTextViewTextSize(R.id.widget_digital_city_name, TypedValue.COMPLEX_UNIT_DIP, small)
setString(R.id.widget_digital_city_time, "setTimeZone", city.zoneId)
setCharSequence(R.id.widget_digital_city_time, "setFormat12Hour", cityPattern)
setCharSequence(R.id.widget_digital_city_time, "setFormat24Hour", cityPattern)
setTextViewTextSize(R.id.widget_digital_city_time, TypedValue.COMPLEX_UNIT_DIP, small)
// Each row after the first sits a little below the one before.
if (index > 0) setViewPadding(R.id.widget_digital_city_name, 0, px(small * 0.4f), 0, 0)
if (index > 0) setViewPadding(R.id.widget_digital_city_time, 0, px(small * 0.4f), 0, 0)
}
addView(R.id.widget_digital_cities, row)
}
}
}
}
/** The time in a primary pill, the date in a smaller tertiary pill under it. */
private fun pillViews(context: Context, showDate: Boolean, w: Float, h: Float): RemoteViews {
// The pill is 2.85em of digits plus 0.5em padding either side, 1.4em tall;
// the date adds a 0.14em gap and a 0.34em-text pill about 0.6em tall.
val time = minOf(w * 0.94f / 3.85f, h * 0.94f / (if (showDate) 2.15f else 1.4f))
val date = time * 0.34f
val px = pxOf(context)
return RemoteViews(context.packageName, R.layout.widget_digital_pill).apply {
setTime(context, R.id.widget_digital_time, time)
setViewPadding(R.id.widget_digital_time, px(time * 0.5f), px(time * 0.14f), px(time * 0.5f), px(time * 0.14f))
setDate(context, R.id.widget_digital_date, showDate, date, "EEEMMMd")
if (showDate) {
setViewPadding(R.id.widget_digital_date_frame, 0, px(time * 0.14f), 0, 0)
setViewPadding(R.id.widget_digital_date, px(date * 0.8f), px(date * 0.3f), px(date * 0.8f), px(date * 0.3f))
} else {
setViewVisibility(R.id.widget_digital_date_frame, View.GONE)
}
}
}
/** Hours over minutes on a Square of [side], sized so both fit inside its rounded corners. */
private fun stackedViews(context: Context, showDate: Boolean, side: Float): RemoteViews {
val digits = side * (if (showDate) 0.3f else 0.34f)
val is24 = DateFormat.is24HourFormat(context)
return RemoteViews(context.packageName, R.layout.widget_digital_stacked).apply {
setClock(R.id.widget_digital_hours, if (is24) "HH" else "hh", digits)
setClock(R.id.widget_digital_minutes, "mm", digits)
setDate(context, R.id.widget_digital_date, showDate, side * 0.075f, "EEEd")
}
}
/** The main time: `h:mm` / `HH:mm` — the 12-hour form without AM/PM. */
private fun RemoteViews.setTime(context: Context, viewId: Int, sizeDp: Float) =
setClock(viewId, if (DateFormat.is24HourFormat(context)) "HH:mm" else "h:mm", sizeDp)
/** One `TextClock` showing [pattern] whatever the 12/24-hour setting — the caller has already picked by it. */
private fun RemoteViews.setClock(viewId: Int, pattern: String, sizeDp: Float) {
setCharSequence(viewId, "setFormat12Hour", pattern)
setCharSequence(viewId, "setFormat24Hour", pattern)
setTextViewTextSize(viewId, TypedValue.COMPLEX_UNIT_DIP, sizeDp)
}
/**
* A date-only `TextClock` — no time tokens — so it reads as a date line but
* still re-renders itself at midnight with no app-side ticking.
*/
private fun RemoteViews.setDate(context: Context, viewId: Int, show: Boolean, sizeDp: Float, skeleton: String) {
if (!show) {
setViewVisibility(viewId, View.GONE)
return
}
setClock(viewId, DateFormat.getBestDateTimePattern(currentLocale(context), skeleton), sizeDp)
}
private fun pxOf(context: Context): (Float) -> Int {
val density = context.resources.displayMetrics.density
return { dp -> (dp * density).toInt() }
}
@@ -4,22 +4,21 @@ import android.content.Context
import android.content.Intent
import android.text.format.DateFormat
import androidx.compose.runtime.Composable
import androidx.compose.ui.unit.dp
import androidx.glance.GlanceModifier
import androidx.glance.LocalContext
import androidx.glance.text.Text
import androidx.glance.GlanceTheme
import androidx.glance.action.clickable
import androidx.glance.LocalContext
import androidx.glance.LocalSize
import androidx.glance.appwidget.action.actionStartActivity
import androidx.glance.appwidget.cornerRadius
import androidx.glance.background
import androidx.glance.layout.Alignment
import androidx.glance.layout.Box
import androidx.glance.layout.Column
import androidx.glance.layout.fillMaxSize
import androidx.glance.layout.Row
import androidx.glance.layout.Spacer
import androidx.glance.layout.padding
import androidx.glance.layout.width
import androidx.glance.text.FontWeight
import androidx.glance.text.Text
import androidx.glance.text.TextStyle
import androidx.glance.unit.ColorProvider
import de.jeanlucmakiola.clockula.MainActivity
import de.jeanlucmakiola.clockula.R
import de.jeanlucmakiola.clockula.alarm.AlarmIntents
@@ -29,32 +28,97 @@ import java.time.format.DateTimeFormatter
import java.time.format.FormatStyle
import java.time.format.TextStyle as JavaTextStyle
import java.util.Locale
import de.jeanlucmakiola.clockula.widget.di.WidgetEntryPoint
import de.jeanlucmakiola.clockula.domain.widget.NextAlarmWidgetPresenter
import de.jeanlucmakiola.clockula.domain.alarm.UpcomingAlarm
import androidx.compose.runtime.remember
import androidx.compose.runtime.getValue
import androidx.compose.runtime.collectAsState
/** The next-alarm widget's content, given an already-resolved [model]. */
/**
* Reads the upcoming alarms reactively, then renders [NextAlarmWidgetContent].
* 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 NextAlarmWidgetRoot(entryPoint: WidgetEntryPoint, initial: List<UpcomingAlarm>) {
val upcoming by remember { entryPoint.alarmEngine().upcoming() }.collectAsState(initial = initial)
val model = remember(upcoming) {
NextAlarmWidgetPresenter.present(upcoming = upcoming, now = entryPoint.wallClock().now(), zone = entryPoint.zoneProvider().current())
}
NextAlarmWidgetContent(model)
}
/**
* The next-alarm widget's content, given an already-resolved [model]: a
* Cookie badge carrying the alarm glyph, then the day, the time — the
* largest thing on it — and the label, all sized from the widget's live size.
*/
@Composable
fun NextAlarmWidgetContent(model: NextAlarmWidgetModel) {
val context = LocalContext.current
GlanceTheme(WidgetDefaults.Colors) {
Box(
modifier = GlanceModifier
.fillMaxSize()
.background(GlanceTheme.colors.widgetBackground)
.cornerRadius(WidgetDefaults.CornerRadius)
.padding(WidgetDefaults.ContentPadding)
.clickable(actionStartActivity(showAlarmsIntent(context))),
contentAlignment = Alignment.CenterStart,
) {
val size = LocalSize.current
val w = size.width.value
val h = size.height.value
val pad = (minOf(w, h) * 0.14f).coerceIn(10f, 22f)
val innerH = h - 2 * pad
val innerW = w - 2 * pad
// The badge takes the card's height, but never more than a third of
// its width, so a narrow widget keeps room for the time.
val badge = minOf(innerH, innerW * 0.34f).coerceAtMost(96f)
val gap = badge * 0.22f
val textW = innerW - badge - gap
WidgetCard(onClick = actionStartActivity(showAlarmsIntent(context)), contentAlignment = Alignment.CenterStart) {
Row(modifier = GlanceModifier.padding(pad.dp), verticalAlignment = Alignment.CenterVertically) {
ShapedIcon(
icon = if (model.hasAlarm) R.drawable.widget_ic_alarm else R.drawable.widget_ic_alarm_off,
contentDescription = null,
width = badge.dp,
height = badge.dp,
container = if (model.hasAlarm) GlanceTheme.colors.primaryContainer else GlanceTheme.colors.secondaryContainer,
content = if (model.hasAlarm) GlanceTheme.colors.onPrimaryContainer else GlanceTheme.colors.onSecondaryContainer,
shape = R.drawable.widget_shape_cookie,
iconFraction = 0.48f,
)
Spacer(GlanceModifier.width(gap.dp))
if (!model.hasAlarm) {
Text(
text = context.getString(R.string.widget_next_alarm_empty),
style = bodyStyle(GlanceTheme.colors.onSurfaceVariant),
style = TextStyle(
color = GlanceTheme.colors.onSurfaceVariant,
fontSize = fittedSp(context, minOf(innerH * 0.26f, textW / 7f).coerceIn(11f, 20f)),
fontWeight = FontWeight.Medium,
),
maxLines = 2,
)
} else {
val time = timeLabel(context, model)
val hasLabel = model.label.isNotBlank()
// Caption + time (+ label) stacked in the badge's height;
// the time's width is about 0.62em per character.
val lines = if (hasLabel) 2.1f else 1.65f
val timeSize = minOf(innerH / lines, textW / (time.length * 0.62f)).coerceAtLeast(14f)
val caption = (timeSize * 0.36f).coerceIn(11f, 18f)
Column {
Text(dayLabel(context, model), style = captionStyle(GlanceTheme.colors.onSurfaceVariant))
Text(timeLabel(context, model), style = timeStyle(GlanceTheme.colors.onSurface))
if (model.label.isNotBlank()) {
Text(model.label, style = bodyStyle(GlanceTheme.colors.onSurfaceVariant))
Text(
text = dayLabel(context, model),
style = TextStyle(color = GlanceTheme.colors.primary, fontSize = fittedSp(context, caption), fontWeight = FontWeight.Medium),
maxLines = 1,
)
Text(
text = time,
style = TextStyle(color = GlanceTheme.colors.onSurface, fontSize = fittedSp(context, timeSize), fontWeight = FontWeight.Medium),
maxLines = 1,
)
if (hasLabel) {
Text(
text = model.label,
style = TextStyle(color = GlanceTheme.colors.onSurfaceVariant, fontSize = fittedSp(context, caption)),
maxLines = 1,
)
}
}
}
@@ -82,10 +146,3 @@ private fun dayLabel(context: Context, model: NextAlarmWidgetModel): String = wh
model.dayOfWeek != null -> model.dayOfWeek.getDisplayName(JavaTextStyle.FULL, Locale.getDefault())
else -> ""
}
private fun timeStyle(color: ColorProvider) =
TextStyle(color = color, fontSize = WidgetDefaults.PrimaryTextSize, fontWeight = FontWeight.Medium)
private fun bodyStyle(color: ColorProvider) = TextStyle(color = color, fontSize = WidgetDefaults.BodyTextSize)
private fun captionStyle(color: ColorProvider) = TextStyle(color = color, fontSize = WidgetDefaults.CaptionTextSize)
@@ -0,0 +1,174 @@
package de.jeanlucmakiola.clockula.ui.widget
import android.content.Context
import android.content.Intent
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
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.LocalGlanceId
import androidx.glance.LocalSize
import androidx.glance.appwidget.GlanceAppWidgetManager
import androidx.glance.appwidget.action.actionSendBroadcast
import androidx.glance.background
import androidx.glance.currentState
import androidx.glance.layout.Alignment
import androidx.glance.layout.Column
import androidx.glance.layout.fillMaxSize
import androidx.glance.layout.size
import androidx.glance.semantics.contentDescription
import androidx.glance.semantics.semantics
import de.jeanlucmakiola.clockula.R
import de.jeanlucmakiola.clockula.domain.Timer
import de.jeanlucmakiola.clockula.domain.format.ClockFormat
import de.jeanlucmakiola.clockula.domain.timer.TimerMode
import de.jeanlucmakiola.clockula.domain.widget.QuickTimerWidgetModel
import de.jeanlucmakiola.clockula.domain.widget.QuickTimerWidgetPresenter
import de.jeanlucmakiola.clockula.domain.widget.TimerWidgetItem
import de.jeanlucmakiola.clockula.domain.widget.parseQuickTimerDuration
import de.jeanlucmakiola.clockula.timer.QuickTimerIntents
import de.jeanlucmakiola.clockula.timer.TimerIntents
import de.jeanlucmakiola.clockula.timer.receiver.QuickTimerReceiver
import de.jeanlucmakiola.clockula.widget.WIDGET_QUICK_TIMER_DURATION_KEY
import de.jeanlucmakiola.clockula.widget.WIDGET_QUICK_TIMER_ID_KEY
import de.jeanlucmakiola.clockula.widget.di.WidgetEntryPoint
/**
* Reads this instance's duration and started-timer id, and the timers
* themselves, reactively, then renders [QuickTimerWidgetContent]. Collected
* inside the composition for the reason `TimerWidgetRoot` gives.
*/
@Composable
fun QuickTimerWidgetRoot(entryPoint: WidgetEntryPoint, initial: List<Timer>) {
val prefs = currentState<Preferences>()
val duration = parseQuickTimerDuration(prefs[WIDGET_QUICK_TIMER_DURATION_KEY])
val timerId = prefs[WIDGET_QUICK_TIMER_ID_KEY]
val timers by remember { entryPoint.timerRepository().timers() }.collectAsState(initial = initial)
val elapsedRealtime = remember(timers, timerId) { entryPoint.elapsedRealtimeClock().elapsedRealtime() }
val model = remember(duration, timerId, timers) {
QuickTimerWidgetPresenter.present(duration, timerId, timers, elapsedRealtime, entryPoint.wallClock().now())
}
QuickTimerWidgetContent(model, elapsedRealtime.inWholeMilliseconds)
}
/**
* The quick-timer widget: a Scallop floating on the home screen, like the
* analog faces, that is a timer of its own. At rest it is a play glyph over
* its duration, and tapping it starts a transient timer of that length
* (`timer/receiver/QuickTimerReceiver.kt`). While that timer is active the
* Scallop shows its countdown in the same layout, and tapping it again
* resets (or, once it rings, stops) it — which deletes the timer, so the
* widget is at rest again. [elapsedRealtimeNow] is the
* `SystemClock.elapsedRealtime()` millis the model was resolved at, the
* countdown chronometer's time base.
*/
@Composable
fun QuickTimerWidgetContent(model: QuickTimerWidgetModel, elapsedRealtimeNow: Long) {
val context = LocalContext.current
val size = LocalSize.current
val side = minOf(size.width, size.height)
val s = side.value
val active = model.active
val onClick = if (active == null) {
val appWidgetId = GlanceAppWidgetManager(context).getAppWidgetId(LocalGlanceId.current)
actionSendBroadcast(startIntent(context, model, appWidgetId))
} else {
// Reset is a transient timer's dismissal: it stops (or stops ringing)
// and is deleted, so the widget is at rest again.
actionSendBroadcast(timerActionIntent(context, TimerIntents.ACTION_RESET, active.id))
}
CenteredSquare(side = side, onClick = onClick) {
val shape = GlanceModifier
.fillMaxSize()
.background(ImageProvider(R.drawable.widget_shape_scallop), colorFilter = ColorFilter.tint(GlanceTheme.colors.primaryContainer))
if (active == null) {
AtRest(context, model, s, shape)
} else {
Running(context, active, elapsedRealtimeNow, s, shape)
}
}
}
/** At rest: a play glyph over the duration it starts. */
@Composable
private fun AtRest(context: Context, model: QuickTimerWidgetModel, s: Float, shape: GlanceModifier) {
val label = ClockFormat.countdown(model.duration)
GlyphOverTime(
icon = R.drawable.widget_ic_play,
description = context.getString(R.string.widget_quick_timer_start, label),
s = s,
shape = shape,
) { size -> WidgetReadout(label, size, R.color.widget_on_primary_container) }
}
/**
* Its timer active: the same layout as [AtRest] — the glyph says what a tap
* does now (reset, or stop once it rings), over the live countdown.
*/
@Composable
private fun Running(context: Context, item: TimerWidgetItem, elapsedRealtimeNow: Long, s: Float, shape: GlanceModifier) {
val expired = item.mode == TimerMode.EXPIRED
GlyphOverTime(
icon = if (expired) R.drawable.widget_ic_stop else R.drawable.widget_ic_replay,
description = context.getString(if (expired) R.string.timer_stop else R.string.timer_reset),
s = s,
shape = shape,
) { size ->
if (item.mode == TimerMode.RUNNING) {
WidgetChronometer(
layoutRes = R.layout.widget_chronometer_on_primary,
viewId = R.id.widget_chronometer_on_primary,
base = elapsedRealtimeNow + item.remaining.inWholeMilliseconds,
countDown = true,
textSizeDp = size,
)
} else {
val text = if (expired) context.getString(R.string.widget_timer_expired) else ClockFormat.countdown(item.remaining)
// Shrunk to the same width should the word be longer than a time.
WidgetReadout(text, size * minOf(1f, 5f / text.length), R.color.widget_on_primary_container)
}
}
}
/**
* The one layout both states share: [icon] over a time readout, sized for
* five characters ("01:02", what a running chronometer shows) so the readout
* keeps its size from rest to running and fits the shape's inner ~60%.
*/
@Composable
private fun GlyphOverTime(
icon: Int,
description: String,
s: Float,
shape: GlanceModifier,
readout: @Composable (sizeDp: Float) -> Unit,
) {
Column(
modifier = shape.semantics { contentDescription = description },
horizontalAlignment = Alignment.CenterHorizontally,
verticalAlignment = Alignment.CenterVertically,
) {
Image(
provider = ImageProvider(icon),
contentDescription = null,
modifier = GlanceModifier.size((s * 0.26f).dp),
colorFilter = ColorFilter.tint(GlanceTheme.colors.onPrimaryContainer),
)
readout(minOf(s * 0.2f, s * 0.6f / (5 * 0.6f)))
}
}
private fun startIntent(context: Context, model: QuickTimerWidgetModel, appWidgetId: Int): Intent =
Intent(context, QuickTimerReceiver::class.java)
.setAction(QuickTimerIntents.ACTION_START)
.putExtra(QuickTimerIntents.EXTRA_DURATION_MILLIS, model.duration.inWholeMilliseconds)
.putExtra(QuickTimerIntents.EXTRA_APP_WIDGET_ID, appWidgetId)
@@ -0,0 +1,188 @@
package de.jeanlucmakiola.clockula.ui.widget
import android.content.Context
import android.content.Intent
import androidx.compose.runtime.Composable
import androidx.compose.ui.unit.dp
import androidx.glance.GlanceModifier
import androidx.glance.GlanceTheme
import androidx.glance.LocalContext
import androidx.glance.LocalSize
import androidx.glance.appwidget.action.actionSendBroadcast
import androidx.glance.appwidget.action.actionStartActivity
import androidx.glance.layout.Alignment
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.width
import androidx.glance.text.FontWeight
import androidx.glance.text.Text
import androidx.glance.text.TextStyle
import de.jeanlucmakiola.clockula.MainActivity
import de.jeanlucmakiola.clockula.R
import de.jeanlucmakiola.clockula.domain.StopwatchState
import de.jeanlucmakiola.clockula.domain.format.ClockFormat
import de.jeanlucmakiola.clockula.domain.widget.StopwatchWidgetModel
import de.jeanlucmakiola.clockula.stopwatch.StopwatchIntents
import de.jeanlucmakiola.clockula.stopwatch.receiver.StopwatchActionReceiver
import kotlin.time.Duration.Companion.milliseconds
import kotlinx.coroutines.flow.map
import de.jeanlucmakiola.clockula.widget.di.WidgetEntryPoint
import de.jeanlucmakiola.clockula.domain.widget.StopwatchWidgetPresenter
import de.jeanlucmakiola.clockula.domain.StopwatchRun
import androidx.compose.runtime.remember
import androidx.compose.runtime.getValue
import androidx.compose.runtime.collectAsState
import android.os.SystemClock
/**
* 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.
*/
@Composable
fun StopwatchWidgetRoot(entryPoint: WidgetEntryPoint, initialRun: StopwatchRun, initialLapCount: Int) {
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)
// The same instant the model is resolved at, so the chronometer's base
// is exactly `now - elapsed`.
val elapsedRealtime = remember(run, lapCount) { SystemClock.elapsedRealtime() }
val model = remember(run, lapCount) { StopwatchWidgetPresenter.present(run, elapsedRealtime.milliseconds, lapCount) }
StopwatchWidgetContent(model, elapsedRealtime)
}
/**
* 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).
*/
@Composable
fun StopwatchWidgetContent(model: StopwatchWidgetModel, elapsedRealtimeNow: Long) {
val context = LocalContext.current
val size = LocalSize.current
val w = size.width.value
val h = size.height.value
val pad = (minOf(w, h) * 0.1f).coerceIn(10f, 18f)
val innerW = w - 2 * pad
val innerH = h - 2 * pad
// The chronometer reads "00:00" (or "H:MM:SS"), never shorter.
val readoutChars = maxOf(5, ClockFormat.elapsed(model.elapsed).length)
WidgetCard(onClick = actionStartActivity(showStopwatchIntent(context))) {
if (h < WIDE_BELOW_HEIGHT) {
val button = (innerH * 0.7f).coerceIn(32f, 60f)
val controlsW = button * 1.6f + button * 1.18f
val timeSize = minOf(innerH * 0.62f, (innerW - controlsW) / (readoutChars * 0.6f)).coerceAtLeast(14f)
Row(modifier = GlanceModifier.fillMaxSize().padding(pad.dp), verticalAlignment = Alignment.CenterVertically) {
Column(modifier = GlanceModifier.defaultWeight()) {
Readout(context, model, elapsedRealtimeNow, timeSize)
}
Controls(context, model, button)
}
} else {
val button = (innerH * 0.3f).coerceIn(32f, 64f)
val gap = button * 0.3f
val caption = (button * 0.3f).coerceIn(11f, 16f)
// A Glance `Text` line, font padding included, is ~1.5x its size;
// the readout (no padding) ~1.2x. A little slack keeps rounding
// from clipping the buttons.
val captionH = if (model.lapCount > 0) caption * 1.5f else 0f
val timeSize = minOf((innerH * 0.94f - button - gap - captionH) / 1.2f, innerW / (readoutChars * 0.6f)).coerceAtLeast(14f)
Column(
modifier = GlanceModifier.fillMaxSize().padding(pad.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalAlignment = Alignment.CenterVertically,
) {
Readout(context, model, elapsedRealtimeNow, timeSize)
if (model.lapCount > 0) {
Text(
text = context.getString(R.string.widget_stopwatch_laps, model.lapCount),
style = TextStyle(color = GlanceTheme.colors.primary, fontSize = fittedSp(context, caption), fontWeight = FontWeight.Medium),
maxLines = 1,
)
}
Spacer(GlanceModifier.height(gap.dp))
Controls(context, model, button)
}
}
}
}
/** Under this height (one launcher row, give or take) the controls sit beside the readout. */
private const val WIDE_BELOW_HEIGHT = 120f
@Composable
private fun Readout(context: Context, model: StopwatchWidgetModel, elapsedRealtimeNow: Long, sizeDp: Float) {
if (model.mode == StopwatchState.RUNNING) {
WidgetChronometer(
layoutRes = R.layout.widget_chronometer,
viewId = R.id.widget_chronometer,
base = elapsedRealtimeNow - model.elapsed.inWholeMilliseconds,
countDown = false,
textSizeDp = sizeDp,
)
} else {
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,
)
}
}
/** 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) {
Row(verticalAlignment = Alignment.CenterVertically) {
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
},
),
width = (button * 1.6f).dp,
height = button.dp,
container = GlanceTheme.colors.primary,
content = GlanceTheme.colors.onPrimary,
shape = R.drawable.widget_bg_pill,
onClick = actionSendBroadcast(
actionIntent(context, if (running) StopwatchIntents.ACTION_PAUSE else StopwatchIntents.ACTION_RESUME),
),
)
if (model.mode != StopwatchState.IDLE) {
Spacer(GlanceModifier.width((button * 0.18f).dp))
ShapedIcon(
icon = if (running) R.drawable.widget_ic_flag else R.drawable.widget_ic_replay,
contentDescription = context.getString(if (running) R.string.stopwatch_lap else R.string.stopwatch_reset),
width = button.dp,
height = button.dp,
container = GlanceTheme.colors.secondaryContainer,
content = GlanceTheme.colors.onSecondaryContainer,
onClick = actionSendBroadcast(
actionIntent(context, if (running) StopwatchIntents.ACTION_LAP else StopwatchIntents.ACTION_RESET),
),
)
}
}
}
private fun actionIntent(context: Context, action: String): Intent =
Intent(context, StopwatchActionReceiver::class.java).setAction(action)
private fun showStopwatchIntent(context: Context): Intent =
Intent(context, MainActivity::class.java)
.setAction(StopwatchIntents.ACTION_SHOW_STOPWATCH)
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
@@ -2,31 +2,27 @@ package de.jeanlucmakiola.clockula.ui.widget
import android.content.Context
import android.content.Intent
import android.widget.RemoteViews
import androidx.compose.runtime.Composable
import androidx.compose.ui.unit.dp
import androidx.glance.GlanceModifier
import androidx.glance.GlanceTheme
import androidx.glance.LocalContext
import androidx.glance.text.Text
import androidx.glance.action.clickable
import androidx.glance.appwidget.AndroidRemoteViews
import androidx.glance.LocalSize
import androidx.glance.appwidget.action.actionSendBroadcast
import androidx.glance.appwidget.action.actionStartActivity
import androidx.glance.appwidget.cornerRadius
import androidx.glance.background
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.fillMaxWidth
import androidx.glance.layout.padding
import androidx.glance.layout.Spacer
import androidx.glance.layout.height
import androidx.glance.layout.padding
import androidx.glance.layout.width
import androidx.glance.text.FontWeight
import androidx.glance.text.Text
import androidx.glance.text.TextStyle
import androidx.glance.unit.ColorProvider
import de.jeanlucmakiola.clockula.MainActivity
import de.jeanlucmakiola.clockula.R
import de.jeanlucmakiola.clockula.domain.format.ClockFormat
import de.jeanlucmakiola.clockula.domain.timer.TimerMode
@@ -35,107 +31,234 @@ import de.jeanlucmakiola.clockula.domain.widget.TimerWidgetModel
import de.jeanlucmakiola.clockula.interop.InteropIntents
import de.jeanlucmakiola.clockula.timer.TimerIntents
import de.jeanlucmakiola.clockula.timer.receiver.TimerActionReceiver
import de.jeanlucmakiola.clockula.MainActivity
import de.jeanlucmakiola.clockula.widget.di.WidgetEntryPoint
import de.jeanlucmakiola.clockula.domain.widget.TimerWidgetPresenter
import de.jeanlucmakiola.clockula.domain.Timer
import androidx.compose.runtime.remember
import androidx.compose.runtime.getValue
import androidx.compose.runtime.collectAsState
/** The timer widget's content, given an already-resolved [model]. No 1s ticker — a running row's countdown is the platform chronometer (as `TimerNotifications.kt`). */
/**
* Reads the timers reactively, then renders [TimerWidgetContent].
* 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 TimerWidgetContent(model: TimerWidgetModel) {
fun TimerWidgetRoot(entryPoint: WidgetEntryPoint, initial: List<Timer>) {
val timers by remember { entryPoint.timerRepository().timers() }.collectAsState(initial = initial)
val elapsedRealtime = remember(timers) { entryPoint.elapsedRealtimeClock().elapsedRealtime() }
val model = remember(timers) {
TimerWidgetPresenter.present(timers = timers, elapsedRealtime = elapsedRealtime, wallClock = entryPoint.wallClock().now())
}
TimerWidgetContent(model, elapsedRealtime.inWholeMilliseconds)
}
/** Below this a row's readout and buttons get too small to read or hit, so fewer rows show. */
private const val MIN_ROW_HEIGHT = 56f
/**
* The timer widget's content, given an already-resolved [model]. No 1s
* ticker — a running row's countdown is the platform chronometer (as
* `TimerNotifications.kt`), based at [elapsedRealtimeNow], the
* `SystemClock.elapsedRealtime()` millis the model was resolved at.
*/
@Composable
fun TimerWidgetContent(model: TimerWidgetModel, elapsedRealtimeNow: Long) {
val context = LocalContext.current
GlanceTheme(WidgetDefaults.Colors) {
Box(
modifier = GlanceModifier
.fillMaxSize()
.background(GlanceTheme.colors.widgetBackground)
.cornerRadius(WidgetDefaults.CornerRadius)
.padding(WidgetDefaults.ContentPadding)
.clickable(actionStartActivity(showTimersOrComposeIntent(context, model))),
) {
if (model.items.isEmpty()) {
Box(modifier = GlanceModifier.fillMaxSize(), contentAlignment = Alignment.Center) {
Text(
text = context.getString(R.string.widget_timer_new),
style = TextStyle(color = GlanceTheme.colors.onSurface, fontWeight = FontWeight.Medium),
)
}
} else {
Column(modifier = GlanceModifier.fillMaxSize()) {
model.items.forEachIndexed { index, item ->
if (index > 0) Spacer(modifier = GlanceModifier.height(WidgetDefaults.ContentPadding))
TimerRow(context, item)
}
val size = LocalSize.current
val w = size.width.value
val h = size.height.value
val pad = (minOf(w, h) * 0.1f).coerceIn(10f, 18f)
val innerW = w - 2 * pad
val innerH = h - 2 * pad
WidgetCard(onClick = actionStartActivity(showTimersOrComposeIntent(context, model))) {
if (model.items.isEmpty()) {
NewTimer(context, innerW, innerH)
} else if (model.items.size == 1 && h >= STACK_FROM_HEIGHT) {
SingleTimer(context, model.items.single(), elapsedRealtimeNow, innerW, innerH, pad)
} else {
val visible = model.items.take((innerH / MIN_ROW_HEIGHT).toInt().coerceAtLeast(1))
val rowGap = pad * 0.5f
val rowH = (innerH - rowGap * (visible.size - 1)) / visible.size
Column(modifier = GlanceModifier.fillMaxSize().padding(pad.dp), verticalAlignment = Alignment.CenterVertically) {
visible.forEachIndexed { index, item ->
if (index > 0) Spacer(GlanceModifier.height(rowGap.dp))
TimerRow(context, item, elapsedRealtimeNow, innerW, rowH)
}
}
}
}
}
/** From this height a lone timer gets the stopwatch's layout — a big readout over its buttons — instead of a row. */
private const val STACK_FROM_HEIGHT = 120f
/** One timer on a tall card: label, a readout as large as fits, and its buttons under it. */
@Composable
private fun TimerRow(context: Context, item: TimerWidgetItem) {
private fun SingleTimer(context: Context, item: TimerWidgetItem, elapsedRealtimeNow: Long, innerW: Float, innerH: Float, pad: Float) {
val button = (innerH * 0.3f).coerceIn(32f, 64f)
val gap = button * 0.3f
val hasLabel = item.label.isNotBlank()
val label = (button * 0.3f).coerceIn(11f, 16f)
val labelH = if (hasLabel) label * 1.5f else 0f
val readout = staticReadout(context, item)
val chars = if (item.mode == TimerMode.RUNNING) maxOf(5, readout.length) else readout.length
val timeSize = minOf((innerH * 0.94f - button - gap - labelH) / 1.2f, innerW / (chars * 0.6f)).coerceAtLeast(14f)
Column(
modifier = GlanceModifier.fillMaxSize().padding(pad.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalAlignment = Alignment.CenterVertically,
) {
if (hasLabel) {
Text(
text = item.label,
style = TextStyle(color = GlanceTheme.colors.onSurfaceVariant, fontSize = fittedSp(context, label), fontWeight = FontWeight.Medium),
maxLines = 1,
)
}
Readout(context, item, elapsedRealtimeNow, timeSize)
Spacer(GlanceModifier.height(gap.dp))
Row(verticalAlignment = Alignment.CenterVertically) { Buttons(context, item, button) }
}
}
/** The idle state: a Cookie "+" that, like the whole card, opens a new timer. */
@Composable
private fun NewTimer(context: Context, innerW: Float, innerH: Float) {
val wide = innerW >= innerH * 1.6f
val badge = (if (wide) innerH else minOf(innerW, innerH) * 0.62f).coerceAtMost(96f)
val text = context.getString(R.string.widget_timer_new)
val textStyle = TextStyle(
color = GlanceTheme.colors.onSurface,
fontSize = fittedSp(context, (badge * 0.26f).coerceIn(12f, 22f)),
fontWeight = FontWeight.Medium,
)
val icon = @Composable {
ShapedIcon(
icon = R.drawable.widget_ic_add,
contentDescription = null,
width = badge.dp,
height = badge.dp,
container = GlanceTheme.colors.primaryContainer,
content = GlanceTheme.colors.onPrimaryContainer,
shape = R.drawable.widget_shape_cookie,
)
}
if (wide) {
Row(verticalAlignment = Alignment.CenterVertically) {
icon()
Spacer(GlanceModifier.width((badge * 0.22f).dp))
Text(text, style = textStyle, maxLines = 1)
}
} else {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
icon()
Spacer(GlanceModifier.height((badge * 0.12f).dp))
Text(text, style = textStyle, maxLines = 1)
}
}
}
@Composable
private fun TimerRow(context: Context, item: TimerWidgetItem, elapsedRealtimeNow: Long, rowW: Float, rowH: Float) {
val button = minOf(rowH * 0.8f, rowW * 0.2f, 60f).coerceAtLeast(28f)
val buttonGap = button * 0.18f
val buttons = if (item.mode == TimerMode.EXPIRED) 1 else 2
val textW = rowW - buttons * button - (buttons - 1) * buttonGap - button * 0.25f
val hasLabel = item.label.isNotBlank()
val readout = staticReadout(context, item)
// A running countdown's chronometer reads "01:02", never shorter.
val chars = if (item.mode == TimerMode.RUNNING) maxOf(5, readout.length) else readout.length
val timeSize = minOf(rowH * (if (hasLabel) 0.5f else 0.62f), textW / (chars * 0.6f)).coerceAtLeast(14f)
val labelSize = (timeSize * 0.42f).coerceIn(10f, 16f)
Row(modifier = GlanceModifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
Column(modifier = GlanceModifier.defaultWeight()) {
if (item.label.isNotBlank()) {
Text(item.label, style = TextStyle(color = GlanceTheme.colors.onSurfaceVariant, fontSize = WidgetDefaults.BodyTextSize))
if (hasLabel) {
Text(
text = item.label,
style = TextStyle(color = GlanceTheme.colors.onSurfaceVariant, fontSize = fittedSp(context, labelSize), fontWeight = FontWeight.Medium),
maxLines = 1,
)
}
TimerReadout(context, item)
Readout(context, item, elapsedRealtimeNow, timeSize)
}
TimerActions(context, item)
Spacer(GlanceModifier.width((button * 0.25f).dp))
Buttons(context, item, button)
}
}
@Composable
private fun TimerReadout(context: Context, item: TimerWidgetItem) {
val endsAt = item.endsAtWallClock
if (item.mode == TimerMode.RUNNING && endsAt != null) {
val views = RemoteViews(context.packageName, R.layout.widget_chronometer).apply {
setChronometer(R.id.widget_chronometer, endsAt.toEpochMilliseconds(), null, true)
setChronometerCountDown(R.id.widget_chronometer, true)
}
AndroidRemoteViews(remoteViews = views)
private fun Readout(context: Context, item: TimerWidgetItem, elapsedRealtimeNow: Long, timeSize: Float) {
if (item.mode == TimerMode.RUNNING) {
WidgetChronometer(
layoutRes = R.layout.widget_chronometer,
viewId = R.id.widget_chronometer,
base = elapsedRealtimeNow + item.remaining.inWholeMilliseconds,
countDown = true,
textSizeDp = timeSize,
)
} else {
Text(text = staticReadout(context, item), style = timeStyle(GlanceTheme.colors.onSurface))
WidgetReadout(
text = staticReadout(context, item),
textSizeDp = timeSize,
colorRes = if (item.mode == TimerMode.EXPIRED) R.color.widget_tertiary else R.color.widget_on_surface,
)
}
}
/** The primary action (pause / play / stop) and, unless expired, reset. Laid out by the caller's `Row`. */
@Composable
private fun Buttons(context: Context, item: TimerWidgetItem, button: Float) {
val buttonGap = button * 0.18f
when (item.mode) {
TimerMode.RUNNING -> PrimaryButton(R.drawable.widget_ic_pause, context.getString(R.string.timer_pause), pauseIntent(context, item.id), button, R.drawable.widget_bg_circle)
// Expressive shape change: a paused timer's play button is a
// rounded square, a running one's pause button a circle.
TimerMode.PAUSED -> PrimaryButton(R.drawable.widget_ic_play, context.getString(R.string.timer_resume), resumeIntent(context, item.id), button, R.drawable.widget_shape_square)
else -> PrimaryButton(R.drawable.widget_ic_stop, context.getString(R.string.timer_stop), resetIntent(context, item.id), button, R.drawable.widget_shape_square)
}
if (item.mode != TimerMode.EXPIRED) {
Spacer(GlanceModifier.width(buttonGap.dp))
ShapedIcon(
icon = R.drawable.widget_ic_replay,
contentDescription = context.getString(R.string.timer_reset),
width = button.dp,
height = button.dp,
container = GlanceTheme.colors.secondaryContainer,
content = GlanceTheme.colors.onSecondaryContainer,
onClick = actionSendBroadcast(resetIntent(context, item.id)),
)
}
}
@Composable
private fun PrimaryButton(icon: Int, description: String, intent: Intent, size: Float, shape: Int) {
ShapedIcon(
icon = icon,
contentDescription = description,
width = size.dp,
height = size.dp,
container = GlanceTheme.colors.primary,
content = GlanceTheme.colors.onPrimary,
shape = shape,
onClick = actionSendBroadcast(intent),
)
}
private fun staticReadout(context: Context, item: TimerWidgetItem): String = when (item.mode) {
TimerMode.EXPIRED -> context.getString(R.string.widget_timer_expired)
else -> ClockFormat.countdown(item.remaining)
}
@Composable
private fun TimerActions(context: Context, item: TimerWidgetItem) {
Row {
when (item.mode) {
TimerMode.RUNNING -> ActionButton(context.getString(R.string.timer_pause), pauseIntent(context, item.id))
TimerMode.PAUSED -> ActionButton(context.getString(R.string.timer_resume), resumeIntent(context, item.id))
else -> Unit
}
ActionButton(
text = if (item.mode == TimerMode.EXPIRED) {
context.getString(R.string.timer_stop)
} else {
context.getString(R.string.timer_reset)
},
intent = resetIntent(context, item.id),
)
}
}
@Composable
private fun ActionButton(text: String, intent: Intent) {
Text(
text = text,
style = TextStyle(color = GlanceTheme.colors.primary, fontSize = WidgetDefaults.BodyTextSize),
modifier = GlanceModifier
.padding(horizontal = 8.dp, vertical = 4.dp)
.clickable(actionSendBroadcast(intent)),
)
}
private fun pauseIntent(context: Context, timerId: Long) = timerActionIntent(context, TimerIntents.ACTION_PAUSE, timerId)
private fun resumeIntent(context: Context, timerId: Long) = timerActionIntent(context, TimerIntents.ACTION_RESUME, timerId)
private fun resetIntent(context: Context, timerId: Long) = timerActionIntent(context, TimerIntents.ACTION_RESET, timerId)
private fun timerActionIntent(context: Context, action: String, timerId: Long): Intent =
internal fun timerActionIntent(context: Context, action: String, timerId: Long): Intent =
Intent(context, TimerActionReceiver::class.java)
.setAction(action)
.putExtra(TimerIntents.EXTRA_TIMER_ID, timerId)
@@ -146,6 +269,3 @@ private fun showTimersOrComposeIntent(context: Context, model: TimerWidgetModel)
.setAction(action)
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
}
private fun timeStyle(color: ColorProvider) =
TextStyle(color = color, fontSize = WidgetDefaults.PrimaryTextSize, fontWeight = FontWeight.Medium)
@@ -1,29 +0,0 @@
package de.jeanlucmakiola.clockula.ui.widget
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.TextUnit
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.glance.color.ColorProviders
import androidx.glance.material3.ColorProviders as glanceM3ColorProviders
import de.jeanlucmakiola.clockula.ui.theme.ClockulaDarkFallback
import de.jeanlucmakiola.clockula.ui.theme.ClockulaLightFallback
/** Shared sizing for the three widgets' content, kept in one place like the app's own theme. */
object WidgetDefaults {
val CornerRadius: Dp = 24.dp
val ContentPadding: Dp = 16.dp
val PrimaryTextSize: TextUnit = 28.sp
val BodyTextSize: TextUnit = 14.sp
val CaptionTextSize: TextUnit = 12.sp
/**
* [GlanceTheme]'s own M3 bridge: dynamic colour on API 31+, [ClockulaLightFallback]/
* [ClockulaDarkFallback] otherwise — the same fallback schemes `ui/theme/Theme.kt`
* uses for ordinary Compose (`docs/PLAN.md` §9).
*/
val Colors: ColorProviders = glanceM3ColorProviders(
light = ClockulaLightFallback,
dark = ClockulaDarkFallback,
)
}
@@ -0,0 +1,185 @@
package de.jeanlucmakiola.clockula.ui.widget
import android.content.Context
import android.os.Build
import android.util.TypedValue
import android.widget.RemoteViews
import androidx.annotation.ColorRes
import androidx.annotation.DrawableRes
import androidx.annotation.LayoutRes
import androidx.compose.runtime.Composable
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.TextUnit
import androidx.compose.ui.unit.sp
import androidx.core.os.ConfigurationCompat
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.action.Action
import androidx.glance.action.clickable
import androidx.glance.appwidget.AndroidRemoteViews
import androidx.glance.appwidget.appWidgetBackground
import androidx.glance.appwidget.cornerRadius
import androidx.glance.background
import androidx.glance.layout.Alignment
import androidx.glance.layout.Box
import androidx.glance.layout.fillMaxSize
import androidx.glance.layout.size
import androidx.glance.layout.wrapContentSize
import androidx.glance.unit.ColorProvider
import de.jeanlucmakiola.clockula.R
import de.jeanlucmakiola.clockula.ui.theme.ClockulaDarkFallback
import de.jeanlucmakiola.clockula.ui.theme.ClockulaLightFallback
import de.jeanlucmakiola.floret.glance.FloretGlanceTheme
import de.jeanlucmakiola.floret.glance.WidgetThemeMode
import java.util.Locale
/*
* The pieces every widget but the analog one is built from — the same
* patterns `AnalogClockWidgetContent.kt` set:
* - colour follows the launcher's day/night and, on API 31+, the wallpaper
* by itself ([WidgetTheme]) — no app-side theme pinning;
* - containers are M3 shapes (`widget_bg_*`, `widget_shape_*`), white
* drawables tinted here;
* - every size is a fraction of the widget's live `LocalSize` (`SizeMode.Exact`
* on each widget), so text and controls grow and shrink with the widget
* instead of sitting at a fixed sp in a corner of it.
*/
/**
* The widgets' colours: on API 31+ Glance's own dynamic scheme — resource
* colours the launcher resolves against its day/night and the wallpaper, the
* same system tones as the `widget_*` palette the plain-`RemoteViews` faces
* use — and the app's own fallback schemes below that.
*/
@Composable
internal fun WidgetTheme(content: @Composable () -> Unit) {
FloretGlanceTheme(
lightScheme = ClockulaLightFallback,
darkScheme = ClockulaDarkFallback,
dynamicColor = true,
mode = WidgetThemeMode.SYSTEM,
content = content,
)
}
/**
* A widget's card: the shared surface colour inside the launcher's own corner
* radius, and [onClick] for the whole of it.
*/
@Composable
internal fun WidgetCard(
onClick: Action,
contentAlignment: Alignment = Alignment.Center,
content: @Composable () -> Unit,
) {
WidgetTheme {
val base = GlanceModifier
.fillMaxSize()
.appWidgetBackground()
.background(ImageProvider(R.drawable.widget_bg_card), colorFilter = ColorFilter.tint(GlanceTheme.colors.widgetBackground))
// The drawable already draws the corner; this clips the launcher's own
// press ripple to it, where the platform has a radius to clip to.
val clipped = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
base.cornerRadius(android.R.dimen.system_app_widget_background_radius)
} else {
base
}
Box(modifier = clipped.clickable(onClick), contentAlignment = contentAlignment, content = content)
}
}
/** [icon] centred on a tinted [shape] — an M3 shape for a badge, a circle or pill for a button. */
@Composable
internal fun ShapedIcon(
@DrawableRes icon: Int,
contentDescription: String?,
width: Dp,
height: Dp,
container: ColorProvider,
content: ColorProvider,
@DrawableRes shape: Int = R.drawable.widget_bg_circle,
iconFraction: Float = 0.5f,
onClick: Action? = null,
) {
val modifier = GlanceModifier
.size(width, height)
.background(ImageProvider(shape), colorFilter = ColorFilter.tint(container))
Box(
modifier = if (onClick != null) modifier.clickable(onClick) else modifier,
contentAlignment = Alignment.Center,
) {
Image(
provider = ImageProvider(icon),
contentDescription = contentDescription,
modifier = GlanceModifier.size(minOf(width, height) * iconFraction),
colorFilter = ColorFilter.tint(content),
)
}
}
/**
* A platform `Chronometer` ([layoutRes], ticking in the launcher, not the
* app) at a proportional [textSizeDp]. [base] is in
* `SystemClock.elapsedRealtime()` time, as `RemoteViews.setChronometer` wants.
*/
@Composable
internal fun WidgetChronometer(@LayoutRes layoutRes: Int, viewId: Int, base: Long, countDown: Boolean, textSizeDp: Float) {
val context = LocalContext.current
val views = RemoteViews(context.packageName, layoutRes).apply {
setChronometer(viewId, base, null, true)
setChronometerCountDown(viewId, countDown)
setTextViewTextSize(viewId, TypedValue.COMPLEX_UNIT_DIP, textSizeDp)
}
// Without an explicit wrap, Glance lets embedded views fill their
// parent, and the readout pushes everything after it out of the widget.
AndroidRemoteViews(remoteViews = views, modifier = GlanceModifier.wrapContentSize())
}
/**
* A stopped readout ([text] at [textSizeDp], in palette colour [colorRes]):
* the same padding-free view as [WidgetChronometer]'s, unlike a Glance
* `Text`, so a readout takes the same height whether it is ticking or not.
*/
@Composable
internal fun WidgetReadout(text: String, textSizeDp: Float, @ColorRes colorRes: Int) {
val context = LocalContext.current
val views = RemoteViews(context.packageName, R.layout.widget_readout).apply {
setTextViewText(R.id.widget_readout, text)
setTextViewTextSize(R.id.widget_readout, TypedValue.COMPLEX_UNIT_DIP, textSizeDp)
// From API 31 the launcher resolves the colour against its own
// day/night; before that it can only be resolved here.
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
setColorStateList(R.id.widget_readout, "setTextColor", colorRes)
} else {
setTextColor(R.id.widget_readout, context.getColor(colorRes))
}
}
AndroidRemoteViews(remoteViews = views, modifier = GlanceModifier.wrapContentSize())
}
/**
* [dp] as a Glance text size that stays [dp] whatever the user's font scale:
* sizes here are already fitted to the widget, and scaling them again would
* overflow it.
*/
internal fun fittedSp(context: Context, dp: Float): TextUnit = (dp / context.resources.configuration.fontScale).sp
/** A square [Box] of [side], centred in the widget — for faces that are a shape rather than a card. */
@Composable
internal fun CenteredSquare(side: Dp, onClick: Action, content: @Composable () -> Unit) {
Box(
modifier = GlanceModifier.fillMaxSize().clickable(onClick),
contentAlignment = Alignment.Center,
) {
WidgetTheme {
Box(modifier = GlanceModifier.size(side), contentAlignment = Alignment.Center, content = content)
}
}
}
internal fun currentLocale(context: Context): Locale =
ConfigurationCompat.getLocales(context.resources.configuration).get(0) ?: Locale.getDefault()
@@ -0,0 +1,142 @@
package de.jeanlucmakiola.clockula.ui.widget.config
import android.appwidget.AppWidgetManager
import android.content.Intent
import android.os.Bundle
import android.widget.FrameLayout
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.size
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
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.dp
import androidx.compose.ui.viewinterop.AndroidView
import androidx.glance.appwidget.GlanceAppWidgetManager
import androidx.glance.appwidget.state.getAppWidgetState
import androidx.glance.appwidget.state.updateAppWidgetState
import androidx.glance.state.PreferencesGlanceStateDefinition
import androidx.lifecycle.lifecycleScope
import dagger.hilt.android.AndroidEntryPoint
import de.jeanlucmakiola.clockula.R
import de.jeanlucmakiola.clockula.data.prefs.SettingsPrefs
import de.jeanlucmakiola.clockula.domain.widget.AnalogClockStyle
import de.jeanlucmakiola.clockula.domain.widget.parseAnalogClockStyle
import de.jeanlucmakiola.clockula.ui.theme.ClockulaTheme
import de.jeanlucmakiola.clockula.ui.widget.analogClockViews
import de.jeanlucmakiola.clockula.widget.AnalogClockWidget
import de.jeanlucmakiola.clockula.widget.WIDGET_ANALOG_STYLE_KEY
import de.jeanlucmakiola.floret.prefs.Appearance
import de.jeanlucmakiola.floret.prefs.isDark
import kotlinx.coroutines.launch
import javax.inject.Inject
/**
* The analog widget's entire configuration: a style pick, nothing else — this
* activity *is* [WidgetStylePicker]; tapping an option saves and finishes
* straight away, as the reference picker does (no separate "Save" step).
*/
@AndroidEntryPoint
class AnalogClockConfigActivity : ComponentActivity() {
@Inject lateinit var settingsPrefs: SettingsPrefs
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
val appWidgetId = intent?.getIntExtra(AppWidgetManager.EXTRA_APPWIDGET_ID, AppWidgetManager.INVALID_APPWIDGET_ID)
?: AppWidgetManager.INVALID_APPWIDGET_ID
setResult(RESULT_CANCELED, resultIntent(appWidgetId))
if (appWidgetId == AppWidgetManager.INVALID_APPWIDGET_ID) {
finish()
return
}
// A stale id (the widget removed meanwhile) or one that isn't ours
// throws here; there is nothing to configure, so just close.
val glanceId = runCatching { GlanceAppWidgetManager(this).getGlanceIdBy(appWidgetId) }.getOrNull()
if (glanceId == null) {
finish()
return
}
enableEdgeToEdge()
setContent {
val appearance by settingsPrefs.appearance.collectAsState(initial = Appearance())
var style by remember { mutableStateOf(AnalogClockStyle.ROUND) }
LaunchedEffect(glanceId) {
val state = getAppWidgetState(this@AnalogClockConfigActivity, PreferencesGlanceStateDefinition, glanceId)
style = parseAnalogClockStyle(state[WIDGET_ANALOG_STYLE_KEY])
}
val darkTheme = appearance.themeMode.isDark(isSystemInDarkTheme())
ClockulaTheme(darkTheme = darkTheme, dynamicColor = appearance.dynamicColor) {
Surface(modifier = Modifier.fillMaxSize(), color = MaterialTheme.colorScheme.surface) {}
WidgetStylePicker(
title = stringResource(R.string.widget_analog_config_title),
options = AnalogClockStyle.entries,
selected = style,
label = { stringResource(analogStyleLabel(it)) },
preview = { AnalogStylePreview(it) },
onSelect = { picked ->
style = picked
lifecycleScope.launch {
updateAppWidgetState(this@AnalogClockConfigActivity, glanceId) { prefs ->
prefs[WIDGET_ANALOG_STYLE_KEY] = picked.name
}
AnalogClockWidget().update(this@AnalogClockConfigActivity, glanceId)
setResult(RESULT_OK, resultIntent(appWidgetId))
finish()
}
},
onDismiss = { finish() },
)
}
}
}
private fun resultIntent(appWidgetId: Int) =
Intent().putExtra(AppWidgetManager.EXTRA_APPWIDGET_ID, appWidgetId)
}
internal fun analogStyleLabel(style: AnalogClockStyle): Int = when (style) {
AnalogClockStyle.ROUND -> R.string.analog_style_round
AnalogClockStyle.NUMERAL -> R.string.analog_style_numeral
AnalogClockStyle.BADGE -> R.string.analog_style_badge
AnalogClockStyle.COOKIE -> R.string.analog_style_cookie
AnalogClockStyle.CLOVER -> R.string.analog_style_clover
AnalogClockStyle.PETAL -> R.string.analog_style_petal
AnalogClockStyle.SUNNY -> R.string.analog_style_sunny
AnalogClockStyle.SQUARE -> R.string.analog_style_square
AnalogClockStyle.SCALLOP -> R.string.analog_style_scallop
}
/**
* The real widget face, not a redrawing: inflates [analogClockViews] — the
* same `RemoteViews` the placed widget renders — in-app, so the `AnalogClock`
* ticks and the dial, numerals and colours match exactly.
*/
@Composable
private fun AnalogStylePreview(style: AnalogClockStyle) {
val context = LocalContext.current
val views = remember(style) { analogClockViews(context, style, PreviewSize.value) }
AndroidView(
factory = { FrameLayout(it) },
update = { frame ->
frame.removeAllViews()
frame.addView(views.apply(frame.context, frame))
},
modifier = Modifier.size(PreviewSize),
)
}
private val PreviewSize = 180.dp
@@ -0,0 +1,232 @@
package de.jeanlucmakiola.clockula.ui.widget.config
import android.appwidget.AppWidgetManager
import android.content.Intent
import android.os.Bundle
import android.widget.FrameLayout
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
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.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.compose.ui.viewinterop.AndroidView
import androidx.glance.appwidget.GlanceAppWidgetManager
import androidx.glance.appwidget.state.getAppWidgetState
import androidx.glance.appwidget.state.updateAppWidgetState
import androidx.glance.state.PreferencesGlanceStateDefinition
import androidx.lifecycle.lifecycleScope
import dagger.hilt.android.AndroidEntryPoint
import de.jeanlucmakiola.clockula.R
import de.jeanlucmakiola.clockula.data.prefs.SettingsPrefs
import de.jeanlucmakiola.clockula.data.worldclocks.WorldClockRepository
import de.jeanlucmakiola.clockula.data.zones.ZoneNames
import de.jeanlucmakiola.clockula.domain.widget.DigitalClockSettings
import de.jeanlucmakiola.clockula.domain.widget.DigitalClockStyle
import de.jeanlucmakiola.clockula.domain.widget.showsWorldClocks
import de.jeanlucmakiola.clockula.ui.theme.ClockulaTheme
import de.jeanlucmakiola.clockula.ui.widget.ClockWidgetCity
import de.jeanlucmakiola.clockula.ui.widget.digitalClockViews
import de.jeanlucmakiola.clockula.ui.widget.widgetCities
import de.jeanlucmakiola.clockula.widget.ClockWidget
import de.jeanlucmakiola.clockula.widget.WIDGET_DIGITAL_STYLE_KEY
import de.jeanlucmakiola.clockula.widget.WIDGET_SHOW_DATE_KEY
import de.jeanlucmakiola.clockula.widget.WIDGET_SHOW_WORLD_CLOCKS_KEY
import de.jeanlucmakiola.clockula.widget.parseDigitalClockSettings
import de.jeanlucmakiola.floret.components.FullScreenPicker
import de.jeanlucmakiola.floret.components.GroupedRow
import de.jeanlucmakiola.floret.components.Position
import de.jeanlucmakiola.floret.prefs.Appearance
import de.jeanlucmakiola.floret.prefs.isDark
import kotlinx.coroutines.launch
import javax.inject.Inject
/**
* Picks the digital widget instance's style and the two switches. Runs when
* the widget is placed (below API 31, or whenever the launcher doesn't honour
* `configuration_optional`) and again from "Edit widget".
*/
@AndroidEntryPoint
class ClockWidgetConfigActivity : ComponentActivity() {
@Inject lateinit var settingsPrefs: SettingsPrefs
@Inject lateinit var worldClockRepository: WorldClockRepository
@Inject lateinit var zoneNames: ZoneNames
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
val appWidgetId = intent?.getIntExtra(AppWidgetManager.EXTRA_APPWIDGET_ID, AppWidgetManager.INVALID_APPWIDGET_ID)
?: AppWidgetManager.INVALID_APPWIDGET_ID
// Backing out cancels the placement, as the framework expects.
setResult(RESULT_CANCELED, resultIntent(appWidgetId))
if (appWidgetId == AppWidgetManager.INVALID_APPWIDGET_ID) {
finish()
return
}
// A stale id (the widget removed meanwhile) or one that isn't ours
// throws here; there is nothing to configure, so just close.
val glanceId = runCatching { GlanceAppWidgetManager(this).getGlanceIdBy(appWidgetId) }.getOrNull()
if (glanceId == null) {
finish()
return
}
enableEdgeToEdge()
setContent {
val appearance by settingsPrefs.appearance.collectAsState(initial = Appearance())
var settings by remember { mutableStateOf(DigitalClockSettings()) }
val cities by produceState(initialValue = emptyList<ClockWidgetCity>()) {
value = widgetCities(worldClockRepository, zoneNames)
}
LaunchedEffect(glanceId) {
val state = getAppWidgetState(this@ClockWidgetConfigActivity, PreferencesGlanceStateDefinition, glanceId)
settings = parseDigitalClockSettings(
style = state[WIDGET_DIGITAL_STYLE_KEY],
showDate = state[WIDGET_SHOW_DATE_KEY],
showWorldClocks = state[WIDGET_SHOW_WORLD_CLOCKS_KEY],
)
}
val darkTheme = appearance.themeMode.isDark(isSystemInDarkTheme())
ClockulaTheme(darkTheme = darkTheme, dynamicColor = appearance.dynamicColor) {
Surface(modifier = Modifier.fillMaxSize(), color = MaterialTheme.colorScheme.surface) {}
ClockWidgetConfigScreen(
settings = settings,
cities = cities,
onSettingsChange = { settings = it },
onSave = {
lifecycleScope.launch {
updateAppWidgetState(this@ClockWidgetConfigActivity, glanceId) { prefs ->
prefs[WIDGET_DIGITAL_STYLE_KEY] = settings.style.name
prefs[WIDGET_SHOW_DATE_KEY] = settings.showDate
prefs[WIDGET_SHOW_WORLD_CLOCKS_KEY] = settings.showWorldClocks
}
ClockWidget().update(this@ClockWidgetConfigActivity, glanceId)
setResult(RESULT_OK, resultIntent(appWidgetId))
finish()
}
},
onDismiss = { finish() },
)
}
}
}
private fun resultIntent(appWidgetId: Int) =
Intent().putExtra(AppWidgetManager.EXTRA_APPWIDGET_ID, appWidgetId)
}
@Composable
private fun ClockWidgetConfigScreen(
settings: DigitalClockSettings,
cities: List<ClockWidgetCity>,
onSettingsChange: (DigitalClockSettings) -> Unit,
onSave: () -> Unit,
onDismiss: () -> Unit,
) {
var showStylePicker by remember { mutableStateOf(false) }
val showsWorldClocks = settings.style.showsWorldClocks
FullScreenPicker(
title = stringResource(R.string.widget_config_title),
onDismiss = onDismiss,
actions = {
TextButton(onClick = onSave) { Text(stringResource(android.R.string.ok)) }
},
) {
// The widget as it will look, live, above the settings that change it.
Box(modifier = Modifier.fillMaxWidth().padding(vertical = 16.dp), contentAlignment = Alignment.Center) {
DigitalStylePreview(settings, cities)
}
GroupedRow(
title = stringResource(R.string.widget_config_style),
summary = stringResource(digitalStyleLabel(settings.style)),
position = Position.Top,
onClick = { showStylePicker = true },
)
GroupedRow(
title = stringResource(R.string.widget_config_show_date),
position = if (showsWorldClocks) Position.Middle else Position.Bottom,
trailing = { Switch(checked = settings.showDate, onCheckedChange = { onSettingsChange(settings.copy(showDate = it)) }) },
onClick = { onSettingsChange(settings.copy(showDate = !settings.showDate)) },
)
// Only the card faces have room for world clocks.
if (showsWorldClocks) {
GroupedRow(
title = stringResource(R.string.widget_config_show_world_clocks),
summary = stringResource(R.string.widget_config_show_world_clocks_hint),
position = Position.Bottom,
trailing = { Switch(checked = settings.showWorldClocks, onCheckedChange = { onSettingsChange(settings.copy(showWorldClocks = it)) }) },
onClick = { onSettingsChange(settings.copy(showWorldClocks = !settings.showWorldClocks)) },
)
}
Spacer(Modifier.height(24.dp))
}
if (showStylePicker) {
WidgetStylePicker(
title = stringResource(R.string.widget_config_style),
options = DigitalClockStyle.entries,
selected = settings.style,
label = { stringResource(digitalStyleLabel(it)) },
preview = { style -> DigitalStylePreview(settings.copy(style = style), cities) },
onSelect = { onSettingsChange(settings.copy(style = it)) },
onDismiss = { showStylePicker = false },
)
}
}
internal fun digitalStyleLabel(style: DigitalClockStyle): Int = when (style) {
DigitalClockStyle.REGULAR -> R.string.clock_face_regular
DigitalClockStyle.LIGHT -> R.string.clock_face_light
DigitalClockStyle.BOLD -> R.string.clock_face_bold
DigitalClockStyle.PILL -> R.string.clock_face_pill
DigitalClockStyle.STACKED -> R.string.clock_face_stacked
}
/**
* The real widget face, not a redrawing: inflates [digitalClockViews] — the
* same `RemoteViews` the placed widget renders — in-app, so the `TextClock`s
* tick and the layout, sizes and colours match exactly.
*/
@Composable
private fun DigitalStylePreview(settings: DigitalClockSettings, cities: List<ClockWidgetCity>) {
val context = LocalContext.current
val views = remember(settings, cities) {
digitalClockViews(context, settings, cities, PreviewWidth.value, PreviewHeight.value)
}
AndroidView(
factory = { FrameLayout(it) },
update = { frame ->
frame.removeAllViews()
frame.addView(views.apply(frame.context, frame))
},
modifier = Modifier.size(PreviewWidth, PreviewHeight),
)
}
/** About a 3x2 widget: wide enough for the card faces, tall enough for a city or two. */
private val PreviewWidth = 260.dp
private val PreviewHeight = 160.dp
@@ -0,0 +1,105 @@
package de.jeanlucmakiola.clockula.ui.widget.config
import android.appwidget.AppWidgetManager
import android.content.Intent
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.glance.appwidget.GlanceAppWidgetManager
import androidx.glance.appwidget.state.getAppWidgetState
import androidx.glance.appwidget.state.updateAppWidgetState
import androidx.glance.state.PreferencesGlanceStateDefinition
import androidx.lifecycle.lifecycleScope
import dagger.hilt.android.AndroidEntryPoint
import de.jeanlucmakiola.clockula.R
import de.jeanlucmakiola.clockula.data.prefs.SettingsPrefs
import de.jeanlucmakiola.clockula.domain.format.ClockFormat
import de.jeanlucmakiola.clockula.domain.timer.TimerPresets
import de.jeanlucmakiola.clockula.domain.widget.QuickTimerWidgetSettings
import de.jeanlucmakiola.clockula.domain.widget.parseQuickTimerDuration
import de.jeanlucmakiola.clockula.ui.theme.ClockulaTheme
import de.jeanlucmakiola.clockula.widget.QuickTimerWidget
import de.jeanlucmakiola.clockula.widget.WIDGET_QUICK_TIMER_DURATION_KEY
import de.jeanlucmakiola.floret.components.OptionPicker
import de.jeanlucmakiola.floret.prefs.Appearance
import de.jeanlucmakiola.floret.prefs.isDark
import kotlinx.coroutines.launch
import javax.inject.Inject
/**
* The quick-timer widget's entire configuration: which built-in duration it
* starts on tap — reuses `domain/timer/TimerPresets.BUILT_IN`, the same list
* the Timers screen's own quick-start row offers, so this picker never
* invents a second set of durations. Like [AnalogClockConfigActivity], tapping
* an option saves and finishes immediately.
*/
@AndroidEntryPoint
class QuickTimerConfigActivity : ComponentActivity() {
@Inject lateinit var settingsPrefs: SettingsPrefs
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
val appWidgetId = intent?.getIntExtra(AppWidgetManager.EXTRA_APPWIDGET_ID, AppWidgetManager.INVALID_APPWIDGET_ID)
?: AppWidgetManager.INVALID_APPWIDGET_ID
setResult(RESULT_CANCELED, resultIntent(appWidgetId))
if (appWidgetId == AppWidgetManager.INVALID_APPWIDGET_ID) {
finish()
return
}
// A stale id (the widget removed meanwhile) or one that isn't ours
// throws here; there is nothing to configure, so just close.
val glanceId = runCatching { GlanceAppWidgetManager(this).getGlanceIdBy(appWidgetId) }.getOrNull()
if (glanceId == null) {
finish()
return
}
enableEdgeToEdge()
setContent {
val appearance by settingsPrefs.appearance.collectAsState(initial = Appearance())
var duration by remember { mutableStateOf(QuickTimerWidgetSettings.DEFAULT) }
LaunchedEffect(glanceId) {
val state = getAppWidgetState(this@QuickTimerConfigActivity, PreferencesGlanceStateDefinition, glanceId)
duration = parseQuickTimerDuration(state[WIDGET_QUICK_TIMER_DURATION_KEY])
}
val darkTheme = appearance.themeMode.isDark(isSystemInDarkTheme())
ClockulaTheme(darkTheme = darkTheme, dynamicColor = appearance.dynamicColor) {
Surface(modifier = Modifier.fillMaxSize(), color = MaterialTheme.colorScheme.surface) {}
OptionPicker(
title = stringResource(R.string.widget_quick_timer_config_title),
options = TimerPresets.BUILT_IN,
selected = duration,
label = { ClockFormat.countdown(it) },
onSelect = { picked ->
lifecycleScope.launch {
updateAppWidgetState(this@QuickTimerConfigActivity, glanceId) { prefs ->
prefs[WIDGET_QUICK_TIMER_DURATION_KEY] = picked.inWholeMilliseconds
}
QuickTimerWidget().update(this@QuickTimerConfigActivity, glanceId)
setResult(RESULT_OK, resultIntent(appWidgetId))
finish()
}
},
onDismiss = { finish() },
)
}
}
}
private fun resultIntent(appWidgetId: Int) =
Intent().putExtra(AppWidgetManager.EXTRA_APPWIDGET_ID, appWidgetId)
}
@@ -0,0 +1,66 @@
package de.jeanlucmakiola.clockula.ui.widget.config
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import de.jeanlucmakiola.floret.components.FullScreenPicker
import de.jeanlucmakiola.floret.components.GroupedListInset
/**
* A full-bleed style picker: one large, live preview per option with its name
* centred below — the layout a visual choice needs (`FullScreenPicker`'s third,
* richest rung), rather than a row-plus-summary list a reader has to imagine.
* The selected option carries a tonal highlight; tapping any option selects it
* and closes the picker immediately, same as [de.jeanlucmakiola.floret.components.OptionPicker].
*/
@Composable
fun <T> WidgetStylePicker(
title: String,
options: List<T>,
selected: T,
label: @Composable (T) -> String,
preview: @Composable (T) -> Unit,
onSelect: (T) -> Unit,
onDismiss: () -> Unit,
) {
FullScreenPicker(title = title, onDismiss = onDismiss) {
options.forEach { option ->
val isSelected = option == selected
Column(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = GroupedListInset, vertical = 10.dp)
.clip(RoundedCornerShape(28.dp))
.let { if (isSelected) it.background(MaterialTheme.colorScheme.secondaryContainer) else it }
.clickable {
onSelect(option)
onDismiss()
}
.padding(16.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
preview(option)
Spacer(Modifier.height(10.dp))
Text(
text = label(option),
style = MaterialTheme.typography.titleMedium,
fontWeight = if (isSelected) FontWeight.Bold else FontWeight.Normal,
color = if (isSelected) MaterialTheme.colorScheme.onSecondaryContainer else MaterialTheme.colorScheme.onSurface,
)
}
}
}
}
@@ -0,0 +1,37 @@
package de.jeanlucmakiola.clockula.widget
import android.content.Context
import androidx.glance.GlanceId
import androidx.glance.appwidget.GlanceAppWidget
import androidx.glance.appwidget.GlanceAppWidgetReceiver
import androidx.glance.appwidget.SizeMode
import androidx.glance.appwidget.provideContent
import de.jeanlucmakiola.clockula.ui.widget.AnalogClockWidgetRoot
/**
* The analog clock widget. Per-instance style is read reactively inside
* [AnalogClockWidgetRoot] from this instance's own Glance state
* (`widget/ClockWidgetState.kt`), written by `AnalogClockConfigActivity` —
* see `widget/ClockWidget.kt`'s doc comment for why that read happens inside
* the composition rather than once up front here.
*
* [SizeMode.Exact]: the default [SizeMode.Single] gives every session a
* fixed, one-time `LocalSize` no matter how the widget is later resized —
* `ui/widget/AnalogClockWidgetContent.kt` reads `LocalSize` to size the dial
* to the widget's actual placed size, so without this a resize would never
* change what's drawn.
*/
class AnalogClockWidget : GlanceAppWidget() {
override val sizeMode: SizeMode = SizeMode.Exact
override suspend fun provideGlance(context: Context, id: GlanceId) {
provideContent {
AnalogClockWidgetRoot()
}
}
}
class AnalogClockWidgetReceiver : GlanceAppWidgetReceiver() {
override val glanceAppWidget: GlanceAppWidget = AnalogClockWidget()
}
@@ -4,27 +4,38 @@ import android.content.Context
import androidx.glance.GlanceId
import androidx.glance.appwidget.GlanceAppWidget
import androidx.glance.appwidget.GlanceAppWidgetReceiver
import androidx.glance.appwidget.SizeMode
import androidx.glance.appwidget.provideContent
import de.jeanlucmakiola.clockula.domain.widget.ClockWidgetPresenter
import de.jeanlucmakiola.clockula.ui.widget.ClockWidgetCity
import de.jeanlucmakiola.clockula.ui.widget.ClockWidgetContent
import de.jeanlucmakiola.clockula.ui.widget.ClockWidgetRoot
import de.jeanlucmakiola.clockula.widget.di.widgetEntryPoint
import kotlinx.coroutines.flow.first
/** The clock widget. City names are resolved through the ICU seam (`ZoneNames`) here, before composing — `ui/widget/` never names `android.icu` itself. */
/**
* The digital clock widget. Per-instance face and settings are
* read reactively inside [ClockWidgetRoot] (`ui/widget/ClockWidgetContent.kt`)
* from this instance's own Glance state (`widget/ClockWidgetState.kt`),
* written by `ClockWidgetConfigActivity`; an instance placed with
* configuration skipped has none set, which [parseStoredClockWidgetState]
* resolves to the defaults that reproduce the pre-M11 layout.
*
* Reading that state via `currentState()` *inside* the composition — rather
* than once up front here — matters: `GlanceAppWidget.update()` reuses a
* still-live session (one lives ~45s) by recomposing in place rather than
* re-running [provideGlance], so a `val` read here before `provideContent`
* would keep showing whatever was true when the session started, no matter
* how many times the config screen saves afterwards.
*/
class ClockWidget : GlanceAppWidget() {
// The default SizeMode.Single would give every session a fixed, one-time
// LocalSize, so `DigitalFaces.kt`'s sizing would never react to an
// actual resize.
override val sizeMode: SizeMode = SizeMode.Exact
override suspend fun provideGlance(context: Context, id: GlanceId) {
val entryPoint = widgetEntryPoint(context)
val selected = ClockWidgetPresenter.present(entryPoint.worldClockRepository().worldClocks().first())
val zoneNames = entryPoint.zoneNames()
val cities = selected.map { clock ->
ClockWidgetCity(
zoneId = clock.zoneId,
cityName = clock.label?.takeIf(String::isNotBlank) ?: zoneNames.cityOf(clock.zoneId) ?: clock.zoneId,
)
provideContent {
ClockWidgetRoot(entryPoint)
}
provideContent { ClockWidgetContent(cities) }
}
}
@@ -0,0 +1,43 @@
package de.jeanlucmakiola.clockula.widget
import androidx.datastore.preferences.core.booleanPreferencesKey
import androidx.datastore.preferences.core.longPreferencesKey
import androidx.datastore.preferences.core.stringPreferencesKey
import de.jeanlucmakiola.clockula.domain.widget.AnalogClockStyle
import de.jeanlucmakiola.clockula.domain.widget.DigitalClockSettings
import de.jeanlucmakiola.clockula.domain.widget.DigitalClockStyle
import de.jeanlucmakiola.clockula.domain.widget.QuickTimerWidgetSettings
import de.jeanlucmakiola.clockula.domain.widget.parseAnalogClockStyle
import de.jeanlucmakiola.clockula.domain.widget.parseDigitalClockStyle
import de.jeanlucmakiola.clockula.domain.widget.parseQuickTimerDuration
/** Per-instance Glance state key holding the digital widget's [DigitalClockStyle] ([name]). */
internal val WIDGET_DIGITAL_STYLE_KEY = stringPreferencesKey("digital_style")
/** Per-instance Glance state key: show the date line below the time. */
internal val WIDGET_SHOW_DATE_KEY = booleanPreferencesKey("show_date")
/** Per-instance Glance state key: show up to three world-clock cities below the face. */
internal val WIDGET_SHOW_WORLD_CLOCKS_KEY = booleanPreferencesKey("show_world_clocks")
/** 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 quick-timer widget's configured duration, in millis. */
internal val WIDGET_QUICK_TIMER_DURATION_KEY = longPreferencesKey("quick_timer_duration_millis")
/** Per-instance Glance state key: the id of the timer this quick-timer widget last started (`timer/receiver/QuickTimerReceiver.kt`). */
internal val WIDGET_QUICK_TIMER_ID_KEY = longPreferencesKey("quick_timer_timer_id")
internal fun parseDigitalClockSettings(style: String?, showDate: Boolean?, showWorldClocks: Boolean?): DigitalClockSettings =
DigitalClockSettings(
style = parseDigitalClockStyle(style),
showDate = showDate ?: true,
showWorldClocks = showWorldClocks ?: false,
)
internal fun quickTimerSettings(durationMillis: Long?): QuickTimerWidgetSettings =
QuickTimerWidgetSettings(duration = parseQuickTimerDuration(durationMillis))
internal val QuickTimerWidgetSettings.durationMillis: Long
get() = duration.inWholeMilliseconds
@@ -4,24 +4,23 @@ import android.content.Context
import androidx.glance.GlanceId
import androidx.glance.appwidget.GlanceAppWidget
import androidx.glance.appwidget.GlanceAppWidgetReceiver
import androidx.glance.appwidget.SizeMode
import androidx.glance.appwidget.provideContent
import de.jeanlucmakiola.clockula.domain.widget.NextAlarmWidgetPresenter
import de.jeanlucmakiola.clockula.ui.widget.NextAlarmWidgetContent
import de.jeanlucmakiola.clockula.ui.widget.NextAlarmWidgetRoot
import de.jeanlucmakiola.clockula.widget.di.widgetEntryPoint
import kotlinx.coroutines.flow.first
/** The next-alarm widget. No `updatePeriodMillis` — [WidgetSync] refreshes it whenever an alarm changes. */
class NextAlarmWidget : GlanceAppWidget() {
// Content is sized from the live `LocalSize` (`ui/widget/WidgetKit.kt`);
// the default SizeMode.Single would freeze it at the placed size.
override val sizeMode: SizeMode = SizeMode.Exact
override suspend fun provideGlance(context: Context, id: GlanceId) {
val entryPoint = widgetEntryPoint(context)
val upcoming = entryPoint.alarmEngine().upcoming().first()
val model = NextAlarmWidgetPresenter.present(
upcoming = upcoming,
now = entryPoint.wallClock().now(),
zone = entryPoint.zoneProvider().current(),
)
provideContent { NextAlarmWidgetContent(model) }
val initial = entryPoint.alarmEngine().upcoming().first()
provideContent { NextAlarmWidgetRoot(entryPoint, initial) }
}
}
@@ -0,0 +1,36 @@
package de.jeanlucmakiola.clockula.widget
import android.content.Context
import androidx.glance.GlanceId
import androidx.glance.appwidget.GlanceAppWidget
import androidx.glance.appwidget.GlanceAppWidgetReceiver
import androidx.glance.appwidget.SizeMode
import androidx.glance.appwidget.provideContent
import de.jeanlucmakiola.clockula.ui.widget.QuickTimerWidgetRoot
import de.jeanlucmakiola.clockula.widget.di.widgetEntryPoint
import kotlinx.coroutines.flow.first
/**
* The quick-timer widget: a one-duration timer of its own. Its configured
* duration (written by `QuickTimerConfigActivity`) and the id of the timer it
* last started (written by `QuickTimerReceiver`) are this instance's Glance
* state (`widget/ClockWidgetState.kt`), read reactively inside
* `QuickTimerWidgetRoot` along with the timers themselves — see
* `widget/ClockWidget.kt`'s doc comment for why.
*/
class QuickTimerWidget : GlanceAppWidget() {
// Content is sized from the live `LocalSize` (`ui/widget/WidgetKit.kt`);
// the default SizeMode.Single would freeze it at the placed size.
override val sizeMode: SizeMode = SizeMode.Exact
override suspend fun provideGlance(context: Context, id: GlanceId) {
val entryPoint = widgetEntryPoint(context)
val initial = entryPoint.timerRepository().timers().first()
provideContent { QuickTimerWidgetRoot(entryPoint, initial) }
}
}
class QuickTimerWidgetReceiver : GlanceAppWidgetReceiver() {
override val glanceAppWidget: GlanceAppWidget = QuickTimerWidget()
}
@@ -0,0 +1,31 @@
package de.jeanlucmakiola.clockula.widget
import android.content.Context
import androidx.glance.GlanceId
import androidx.glance.appwidget.GlanceAppWidget
import androidx.glance.appwidget.GlanceAppWidgetReceiver
import androidx.glance.appwidget.SizeMode
import androidx.glance.appwidget.provideContent
import de.jeanlucmakiola.clockula.ui.widget.StopwatchWidgetRoot
import de.jeanlucmakiola.clockula.widget.di.widgetEntryPoint
import kotlinx.coroutines.flow.first
/** The stopwatch widget. No `updatePeriodMillis` and no per-second tick of its own — a running readout is the platform chronometer. [WidgetSync] refreshes it on every stopwatch write. */
class StopwatchWidget : GlanceAppWidget() {
// Content is sized from the live `LocalSize` (`ui/widget/WidgetKit.kt`);
// the default SizeMode.Single would freeze it at the placed size.
override val sizeMode: SizeMode = SizeMode.Exact
override suspend fun provideGlance(context: Context, id: GlanceId) {
val entryPoint = widgetEntryPoint(context)
val repository = entryPoint.stopwatchRepository()
val initialRun = repository.run().first()
val initialLaps = repository.laps().first()
provideContent { StopwatchWidgetRoot(entryPoint, initialRun, initialLaps.size) }
}
}
class StopwatchWidgetReceiver : GlanceAppWidgetReceiver() {
override val glanceAppWidget: GlanceAppWidget = StopwatchWidget()
}
@@ -4,24 +4,24 @@ import android.content.Context
import androidx.glance.GlanceId
import androidx.glance.appwidget.GlanceAppWidget
import androidx.glance.appwidget.GlanceAppWidgetReceiver
import androidx.glance.appwidget.SizeMode
import androidx.glance.appwidget.provideContent
import de.jeanlucmakiola.clockula.domain.widget.TimerWidgetPresenter
import de.jeanlucmakiola.clockula.ui.widget.TimerWidgetContent
import de.jeanlucmakiola.clockula.ui.widget.TimerWidgetRoot
import de.jeanlucmakiola.clockula.widget.di.widgetEntryPoint
import kotlinx.coroutines.flow.first
/** The running-timer widget. No `updatePeriodMillis`, and no per-second tick of its own — a running row's countdown is the platform chronometer. [WidgetSync] refreshes it on every timer write. */
class TimerWidget : GlanceAppWidget() {
// Content is sized from the live `LocalSize` (`ui/widget/WidgetKit.kt`);
// the default SizeMode.Single would freeze it at the placed size.
override val sizeMode: SizeMode = SizeMode.Exact
override suspend fun provideGlance(context: Context, id: GlanceId) {
val entryPoint = widgetEntryPoint(context)
val timers = entryPoint.timerRepository().timers().first()
val model = TimerWidgetPresenter.present(
timers = timers,
elapsedRealtime = entryPoint.elapsedRealtimeClock().elapsedRealtime(),
wallClock = entryPoint.wallClock().now(),
)
provideContent { TimerWidgetContent(model) }
val repository = entryPoint.timerRepository()
val initial = repository.timers().first()
provideContent { TimerWidgetRoot(entryPoint, initial) }
}
}
@@ -3,6 +3,7 @@ package de.jeanlucmakiola.clockula.widget
import android.content.Context
import dagger.hilt.android.qualifiers.ApplicationContext
import de.jeanlucmakiola.clockula.alarm.AlarmEngine
import de.jeanlucmakiola.clockula.data.stopwatch.StopwatchRepository
import de.jeanlucmakiola.clockula.data.timers.TimerRepository
import de.jeanlucmakiola.clockula.data.worldclocks.WorldClockRepository
import androidx.glance.appwidget.updateAll
@@ -15,12 +16,16 @@ import javax.inject.Singleton
import kotlin.time.Duration.Companion.milliseconds
/**
* Keeps the three widgets in step with the app's own state, event-driven —
* Keeps the widgets in step with the app's own state, event-driven —
* `updatePeriodMillis = 0` on every widget — by collecting the same read-only
* flows the shell already reads (`AlarmEngine.upcoming()`, `TimerRepository.
* timers()`, `WorldClockRepository.worldClocks()`). This covers every engine
* write and every `SystemEventReceiver` path, because the process is alive
* whenever those run — it touches no engine itself, only re-renders.
* timers()`, `StopwatchRepository.run()`, `WorldClockRepository.worldClocks()`).
* This covers every engine write and every `SystemEventReceiver` path,
* because the process is alive whenever those run — it touches no engine
* itself, only re-renders.
*
* The quick-timer widget follows the timers too: it shows the countdown of
* the timer it started (`widget/QuickTimerWidget.kt`).
*
* Started once, from [de.jeanlucmakiola.clockula.ClockulaApp.onCreate], in the
* app's `@ApplicationScope` — never driven by a `Ticker`, so an idle home
@@ -31,6 +36,7 @@ class WidgetSync @Inject constructor(
@ApplicationContext private val context: Context,
private val alarmEngine: AlarmEngine,
private val timers: TimerRepository,
private val stopwatch: StopwatchRepository,
private val worldClocks: WorldClockRepository,
) {
@@ -39,8 +45,12 @@ class WidgetSync @Inject constructor(
combine(
alarmEngine.upcoming(),
timers.timers(),
// A lap leaves the run untouched, so the lap count is watched too.
combine(stopwatch.run(), stopwatch.laps()) { run, laps -> run to laps.size },
worldClocks.worldClocks(),
) { upcoming, timerRows, clocks -> Triple(upcoming, timerRows, clocks) }
) { upcoming, timerRows, stopwatchRun, clocks ->
WidgetSyncState(upcoming, timerRows, stopwatchRun, clocks)
}
.distinctUntilChanged()
// A burst of writes (an import's replace-all, a reorder) collapses
// to one redraw rather than one per row.
@@ -51,10 +61,16 @@ class WidgetSync @Inject constructor(
private suspend fun updateAll() {
NextAlarmWidget().updateAll(context)
TimerWidget().updateAll(context)
StopwatchWidget().updateAll(context)
ClockWidget().updateAll(context)
AnalogClockWidget().updateAll(context)
QuickTimerWidget().updateAll(context)
}
private companion object {
val DEBOUNCE = 150.milliseconds
}
}
/** `combine` has no tuple wider than a `Triple`; this is the four-flow one. */
private data class WidgetSyncState<A, B, C, D>(val a: A, val b: B, val c: C, val d: D)
@@ -6,6 +6,7 @@ import dagger.hilt.InstallIn
import dagger.hilt.android.EntryPointAccessors
import dagger.hilt.components.SingletonComponent
import de.jeanlucmakiola.clockula.alarm.AlarmEngine
import de.jeanlucmakiola.clockula.data.stopwatch.StopwatchRepository
import de.jeanlucmakiola.clockula.data.timers.TimerRepository
import de.jeanlucmakiola.clockula.data.worldclocks.WorldClockRepository
import de.jeanlucmakiola.clockula.data.zones.ZoneNames
@@ -25,6 +26,7 @@ import de.jeanlucmakiola.clockula.domain.time.ZoneProvider
interface WidgetEntryPoint {
fun alarmEngine(): AlarmEngine
fun timerRepository(): TimerRepository
fun stopwatchRepository(): StopwatchRepository
fun worldClockRepository(): WorldClockRepository
fun zoneNames(): ZoneNames
fun wallClock(): WallClock
@@ -0,0 +1,19 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Clover dial: `MaterialShapes.Clover4Leaf`'s cubics, normalized and laid
into this 100x100 viewport at 2..98 (generated once from the library's
own polygon, so it is the real M3 Expressive shape, not a lookalike).
Turned 45° so the lobes sit at 12/3/6/9 under the numerals, and scaled
to 0.88 so the turned lobes stay inside the viewport. Drawn by the dial
`ImageView` of `widget_analog_*.xml`; see `widget_analog_hour_hand.xml`
for the 480dp intrinsic size. -->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="480dp"
android:height="480dp"
android:viewportWidth="100"
android:viewportHeight="100">
<group android:pivotX="50" android:pivotY="50" android:rotation="45" android:scaleX="0.88" android:scaleY="0.88">
<path
android:pathData="@string/widget_shape_path_clover"
android:fillColor="@color/widget_analog_dial_primary" />
</group>
</vector>
@@ -0,0 +1,15 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Cookie dial: `MaterialShapes.Cookie12Sided`'s cubics, normalized and laid into this
100x100 viewport at 2..98 (generated once from the library's own polygon,
so it is the real M3 Expressive shape, not a lookalike). Drawn by the
dial `ImageView` of `widget_analog_*.xml`; see
`widget_analog_hour_hand.xml` for the 480dp intrinsic size. -->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="480dp"
android:height="480dp"
android:viewportWidth="100"
android:viewportHeight="100">
<path
android:pathData="@string/widget_shape_path_cookie"
android:fillColor="@color/widget_analog_dial_primary" />
</vector>
@@ -0,0 +1,15 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Invisible stand-in dial for every analog widget's `AnalogClock`. The
visible dial is drawn by an `ImageView` underneath, so numerals and badges
can sit between dial and hands. `AnalogClock` still needs a dial because it
derives the hands' scale from the dial's intrinsic size, so this matches
the hands' 480dp (see `widget_analog_hour_hand.xml`). -->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="480dp"
android:height="480dp"
android:viewportWidth="100"
android:viewportHeight="100">
<path
android:pathData="M0,0 L100,0 L100,100 L0,100 Z"
android:fillColor="#00000000" />
</vector>
@@ -0,0 +1,15 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Petal dial: `MaterialShapes.Clover8Leaf`'s cubics, normalized and laid into this
100x100 viewport at 2..98 (generated once from the library's own polygon,
so it is the real M3 Expressive shape, not a lookalike). Drawn by the
dial `ImageView` of `widget_analog_*.xml`; see
`widget_analog_hour_hand.xml` for the 480dp intrinsic size. -->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="480dp"
android:height="480dp"
android:viewportWidth="100"
android:viewportHeight="100">
<path
android:pathData="@string/widget_shape_path_petal"
android:fillColor="@color/widget_analog_dial_tertiary" />
</vector>
@@ -0,0 +1,15 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Round, Numeral and Day dial: `MaterialShapes.Circle`'s cubics, normalized and laid into this
100x100 viewport at 2..98 (generated once from the library's own polygon,
so it is the real M3 Expressive shape, not a lookalike). Drawn by the
dial `ImageView` of `widget_analog_*.xml`; see
`widget_analog_hour_hand.xml` for the 480dp intrinsic size. -->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="480dp"
android:height="480dp"
android:viewportWidth="100"
android:viewportHeight="100">
<path
android:pathData="M98.00,50.00 C98.00,54.97 97.22,59.93 95.67,64.71 C92.56,74.28 86.51,82.61 78.37,88.52 C70.24,94.43 60.44,97.61 50.39,97.61 C40.33,97.61 30.54,94.43 22.40,88.52 C14.27,82.61 8.21,74.28 5.11,64.71 C2.00,55.15 2.00,44.85 5.11,35.29 C8.21,25.72 14.27,17.39 22.40,11.48 C30.54,5.57 40.33,2.39 50.39,2.39 C60.44,2.39 70.24,5.57 78.37,11.48 C86.51,17.39 92.56,25.72 95.67,35.29 C97.22,40.07 98.00,45.03 98.00,50.00 Z"
android:fillColor="@color/widget_analog_dial_fill" />
</vector>
@@ -0,0 +1,15 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Scallop dial: `MaterialShapes.Cookie6Sided`'s cubics, normalized and laid into this
100x100 viewport at 2..98 (generated once from the library's own polygon,
so it is the real M3 Expressive shape, not a lookalike). Drawn by the
dial `ImageView` of `widget_analog_*.xml`; see
`widget_analog_hour_hand.xml` for the 480dp intrinsic size. -->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="480dp"
android:height="480dp"
android:viewportWidth="100"
android:viewportHeight="100">
<path
android:pathData="@string/widget_shape_path_scallop"
android:fillColor="@color/widget_analog_dial_fill" />
</vector>
@@ -0,0 +1,15 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Square dial: `MaterialShapes.Square`'s cubics, normalized and laid into this
100x100 viewport at 2..98 (generated once from the library's own polygon,
so it is the real M3 Expressive shape, not a lookalike). Drawn by the
dial `ImageView` of `widget_analog_*.xml`; see
`widget_analog_hour_hand.xml` for the 480dp intrinsic size. -->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="480dp"
android:height="480dp"
android:viewportWidth="100"
android:viewportHeight="100">
<path
android:pathData="@string/widget_shape_path_square"
android:fillColor="@color/widget_analog_dial_tertiary" />
</vector>
@@ -0,0 +1,15 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Sunny dial: `MaterialShapes.Sunny`'s cubics, normalized and laid into this
100x100 viewport at 2..98 (generated once from the library's own polygon,
so it is the real M3 Expressive shape, not a lookalike). Drawn by the
dial `ImageView` of `widget_analog_*.xml`; see
`widget_analog_hour_hand.xml` for the 480dp intrinsic size. -->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="480dp"
android:height="480dp"
android:viewportWidth="100"
android:viewportHeight="100">
<path
android:pathData="@string/widget_shape_path_sunny"
android:fillColor="@color/widget_analog_dial_tertiary" />
</vector>
@@ -0,0 +1,13 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Bold hour hand: a wide capsule, same width as its minute hand, like
Google Clock's default face. See `widget_analog_hour_hand.xml` for the
pivot convention and the 480dp intrinsic size. -->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="480dp"
android:height="480dp"
android:viewportWidth="100"
android:viewportHeight="100">
<path
android:pathData="M45,36 A5,5 0 0,1 55,36 L55,50 A5,5 0 0,1 45,50 Z"
android:fillColor="@color/widget_analog_hand_hour" />
</vector>
@@ -0,0 +1,22 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- `AnalogClock` centres a hand drawable's own bounds on the dial's centre
and rotates around that point — so the pivot is always (50,50) in this
100x100 viewport, and everything below it is the hand's short tail. The
platform's own default hand drawables render as oversized, mismatched
blobs on current devices, so every analog style sets its own via
`android:hand_hour`/`android:hand_minute` (confirmed working attrs; the
camelCase `hourHand`/`minuteHand` names do not exist).
480dp intrinsic size on purpose: `AnalogClock` only ever scales its
drawables *down* to fit the view, never up, so dial and hands must be at
least as large as the biggest widget for the clock to fill it. Dial and
hands share the same intrinsic size so they scale by the same factor. -->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="480dp"
android:height="480dp"
android:viewportWidth="100"
android:viewportHeight="100">
<path
android:pathData="M46.5,29.5 A3.5,3.5 0 0,1 53.5,29.5 L53.5,52 A3.5,3.5 0 0,1 46.5,52 Z"
android:fillColor="@color/widget_analog_hand_hour" />
</vector>
@@ -0,0 +1,16 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Lollipop hour hand: a thin stick ending in a disc. See
`widget_analog_hour_hand.xml` for the pivot convention and the 480dp
intrinsic size. -->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="480dp"
android:height="480dp"
android:viewportWidth="100"
android:viewportHeight="100">
<path
android:pathData="M48,34 L52,34 L52,51 A2,2 0 0,1 48,51 Z"
android:fillColor="@color/widget_analog_hand_hour" />
<path
android:pathData="M50,33m-5,0a5,5 0 1,0 10,0a5,5 0 1,0 -10,0"
android:fillColor="@color/widget_analog_hand_hour" />
</vector>
@@ -0,0 +1,13 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Slim hour hand: a narrow capsule, fully rounded at both ends. See
`widget_analog_hour_hand.xml` for the pivot convention and the 480dp
intrinsic size. -->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="480dp"
android:height="480dp"
android:viewportWidth="100"
android:viewportHeight="100">
<path
android:pathData="M47.75,30 A2.25,2.25 0 0,1 52.25,30 L52.25,50 A2.25,2.25 0 0,1 47.75,50 Z"
android:fillColor="@color/widget_analog_hand_hour" />
</vector>
@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Bold minute hand: a wide capsule. See `widget_analog_hour_hand.xml` for
the pivot convention and the 480dp intrinsic size. -->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="480dp"
android:height="480dp"
android:viewportWidth="100"
android:viewportHeight="100">
<path
android:pathData="M45,21 A5,5 0 0,1 55,21 L55,50 A5,5 0 0,1 45,50 Z"
android:fillColor="@color/widget_analog_hand_minute" />
</vector>
@@ -0,0 +1,17 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- See `widget_analog_hour_hand.xml` for the pivot convention and the 480dp
intrinsic size. Longer and narrower than the hour hand, and its own
colour, same as a real clock's two hands reading differently at a
glance. The centre dot is drawn here so it sits on top of the hour hand. -->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="480dp"
android:height="480dp"
android:viewportWidth="100"
android:viewportHeight="100">
<path
android:pathData="M47.5,15.5 A2.5,2.5 0 0,1 52.5,15.5 L52.5,52 A2.5,2.5 0 0,1 47.5,52 Z"
android:fillColor="@color/widget_analog_hand_minute" />
<path
android:pathData="M50,50m-3.5,0a3.5,3.5 0 1,0 7,0a3.5,3.5 0 1,0 -7,0"
android:fillColor="@color/widget_analog_hand_minute" />
</vector>
@@ -0,0 +1,19 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Lollipop minute hand: a longer, thinner stick ending in a smaller disc,
plus the centre cap. See `widget_analog_hour_hand.xml` for the pivot
convention and the 480dp intrinsic size. -->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="480dp"
android:height="480dp"
android:viewportWidth="100"
android:viewportHeight="100">
<path
android:pathData="M48.6,18 L51.4,18 L51.4,51 A1.4,1.4 0 0,1 48.6,51 Z"
android:fillColor="@color/widget_analog_hand_minute" />
<path
android:pathData="M50,17m-4,0a4,4 0 1,0 8,0a4,4 0 1,0 -8,0"
android:fillColor="@color/widget_analog_hand_minute" />
<path
android:pathData="M50,50m-3.5,0a3.5,3.5 0 1,0 7,0a3.5,3.5 0 1,0 -7,0"
android:fillColor="@color/widget_analog_hand_minute" />
</vector>
@@ -0,0 +1,21 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Slim minute hand: a narrow capsule over a ringed centre cap — a
ring in the minute colour around a dot in the hour colour. See
`widget_analog_hour_hand.xml` for the pivot convention and the 480dp
intrinsic size. Its tip stops at radius 30: Clover's notches reach in to
34, so a longer hand would poke out of that dial at :08, :23, :38, :53. -->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="480dp"
android:height="480dp"
android:viewportWidth="100"
android:viewportHeight="100">
<path
android:pathData="M48.5,21.5 A1.5,1.5 0 0,1 51.5,21.5 L51.5,50 A1.5,1.5 0 0,1 48.5,50 Z"
android:fillColor="@color/widget_analog_hand_minute" />
<path
android:pathData="M50,50m-4,0a4,4 0 1,0 8,0a4,4 0 1,0 -8,0"
android:fillColor="@color/widget_analog_hand_minute" />
<path
android:pathData="M50,50m-1.8,0a1.8,1.8 0 1,0 3.6,0a1.8,1.8 0 1,0 -3.6,0"
android:fillColor="@color/widget_analog_hand_hour" />
</vector>
@@ -0,0 +1,14 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Seconds hand as Google Clock draws it: just a dot orbiting inside the
rim (Sunny: radius 34, clear of its notches at 40). See
`widget_analog_hour_hand.xml` for the pivot convention and the 480dp
intrinsic size. Seconds hands exist on API 31+ only. -->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="480dp"
android:height="480dp"
android:viewportWidth="100"
android:viewportHeight="100">
<path
android:pathData="M50,16m-2.8,0a2.8,2.8 0 1,0 5.6,0a2.8,2.8 0 1,0 -5.6,0"
android:fillColor="@color/widget_analog_second" />
</vector>
@@ -0,0 +1,13 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- A heavier seconds dot near the rim (Square: radius 40, its edge is 48).
See `widget_analog_hour_hand.xml` for the pivot convention and the 480dp
intrinsic size. Seconds hands exist on API 31+ only. -->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="480dp"
android:height="480dp"
android:viewportWidth="100"
android:viewportHeight="100">
<path
android:pathData="M50,10m-4.2,0a4.2,4.2 0 1,0 8.4,0a4.2,4.2 0 1,0 -8.4,0"
android:fillColor="@color/widget_analog_second" />
</vector>
@@ -0,0 +1,15 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- The seconds dot of the dated faces, at radius 37 — inside Cookie's
notches (42). The date opposite it rides on a second,
invisible `AnalogClock`; see `widget_analog_plain.xml`. See
`widget_analog_hour_hand.xml` for the pivot convention and the 480dp
intrinsic size. Seconds hands exist on API 31+ only. -->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="480dp"
android:height="480dp"
android:viewportWidth="100"
android:viewportHeight="100">
<path
android:pathData="M50,13m-3.6,0a3.6,3.6 0 1,0 7.2,0a3.6,3.6 0 1,0 -7.2,0"
android:fillColor="@color/widget_analog_second" />
</vector>
@@ -0,0 +1,14 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Petal's seconds dot, at radius 32 — well inside Clover8Leaf's notches
(41), where the dated faces' radius-37 dot sat too close to the edge.
See `widget_analog_hour_hand.xml` for the pivot convention and the
480dp intrinsic size. Seconds hands exist on API 31+ only. -->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="480dp"
android:height="480dp"
android:viewportWidth="100"
android:viewportHeight="100">
<path
android:pathData="M50,18m-3.4,0a3.4,3.4 0 1,0 6.8,0a3.4,3.4 0 1,0 -6.8,0"
android:fillColor="@color/widget_analog_second" />
</vector>
@@ -0,0 +1,15 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Scallop's seconds dot: the dated faces' dot, moved in to radius 34 so
it keeps clear of Cookie6Sided's notches (41). The date opposite it is
unchanged; see `widget_analog_second_dot_dated.xml`. See
`widget_analog_hour_hand.xml` for the pivot convention and the 480dp
intrinsic size. Seconds hands exist on API 31+ only. -->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="480dp"
android:height="480dp"
android:viewportWidth="100"
android:viewportHeight="100">
<path
android:pathData="M50,16m-3.6,0a3.6,3.6 0 1,0 7.2,0a3.6,3.6 0 1,0 -7.2,0"
android:fillColor="@color/widget_analog_second" />
</vector>
@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- The Badge analog face's day-of-week pill — resource-qualified colour, not
`?android:attr/colorAccent`; see `values/colors.xml`'s comment. -->
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="rectangle">
<solid android:color="@color/widget_analog_badge_pill" />
<corners android:radius="20dp" />
</shape>
@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- A widget's card. White: every user tints it with a `widget_*` palette
colour (`backgroundTint` in a layout, a Glance colour filter in code), so
one drawable serves every container role. -->
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="rectangle">
<solid android:color="#FFFFFFFF" />
<corners android:radius="@dimen/widget_corner_radius" />
</shape>
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- A circle, white so its user tints it — see `widget_bg_card.xml`. -->
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="oval">
<solid android:color="#FFFFFFFF" />
</shape>
@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- A fully rounded pill, white so its user tints it — see `widget_bg_card.xml`. -->
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="rectangle">
<solid android:color="#FFFFFFFF" />
<corners android:radius="1000dp" />
</shape>
@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- The Bold digital face's card. Its colour is baked in rather than tinted: these
sit in plain `RemoteViews` layouts, where a runtime tint would resolve in
the app's process instead of the launcher's (`values/colors.xml`). -->
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="rectangle">
<solid android:color="@color/widget_primary_container" />
<corners android:radius="@dimen/widget_corner_radius" />
</shape>
@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- The Classic and Thin digital faces' card. Its colour is baked in rather than tinted: these
sit in plain `RemoteViews` layouts, where a runtime tint would resolve in
the app's process instead of the launcher's (`values/colors.xml`). -->
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="rectangle">
<solid android:color="@color/widget_surface" />
<corners android:radius="@dimen/widget_corner_radius" />
</shape>
@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- The Pill digital face's time pill. Its colour is baked in rather than tinted: these
sit in plain `RemoteViews` layouts, where a runtime tint would resolve in
the app's process instead of the launcher's (`values/colors.xml`). -->
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="rectangle">
<solid android:color="@color/widget_primary_container" />
<corners android:radius="1000dp" />
</shape>
@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- The Pill digital face's date pill. Its colour is baked in rather than tinted: these
sit in plain `RemoteViews` layouts, where a runtime tint would resolve in
the app's process instead of the launcher's (`values/colors.xml`). -->
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="rectangle">
<solid android:color="@color/widget_tertiary_container" />
<corners android:radius="1000dp" />
</shape>
@@ -0,0 +1,13 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- The Stacked digital face's `MaterialShapes.Square`, colour baked in —
see `widget_digital_card_surface.xml`. 480dp like the analog dials, so
the fitCenter `ImageView` only ever scales it down. -->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="480dp"
android:height="480dp"
android:viewportWidth="100"
android:viewportHeight="100">
<path
android:pathData="@string/widget_shape_path_square"
android:fillColor="@color/widget_tertiary_container" />
</vector>
@@ -0,0 +1,11 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Material Symbols "add", white so its user tints it. -->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:pathData="M19,13h-6v6h-2v-6H5v-2h6V5h2v6h6v2z"
android:fillColor="#FFFFFFFF" />
</vector>
@@ -0,0 +1,11 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Material Symbols "alarm", white so its user tints it. -->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:pathData="M22,5.72l-4.6,-3.86 -1.29,1.53 4.6,3.86L22,5.72zM7.88,3.39L6.6,1.86 2,5.71l1.29,1.53 4.59,-3.85zM12.5,8H11v6l4.75,2.85 0.75,-1.23 -4,-2.37V8zM12,4c-4.97,0 -9,4.03 -9,9s4.02,9 9,9c4.97,0 9,-4.03 9,-9s-4.03,-9 -9,-9zM12,20c-3.87,0 -7,-3.13 -7,-7s3.13,-7 7,-7 7,3.13 7,7 -3.13,7 -7,7z"
android:fillColor="#FFFFFFFF" />
</vector>
@@ -0,0 +1,11 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Material Symbols "alarm_off", white so its user tints it. -->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:pathData="M12,6c3.87,0 7,3.13 7,7 0,0.84 -0.16,1.65 -0.43,2.4l1.52,1.52c0.58,-1.19 0.91,-2.51 0.91,-3.92 0,-4.97 -4.03,-9 -9,-9 -1.41,0 -2.73,0.33 -3.92,0.91L9.6,6.43C10.35,6.16 11.16,6 12,6zM22,5.72l-4.6,-3.86 -1.29,1.53 4.6,3.86L22,5.72zM2.92,2.29L1.65,3.57 2.98,4.9l-1.11,0.93 1.42,1.42 1.11,-0.94 0.8,0.8C3.83,8.69 3,10.75 3,13c0,4.97 4.02,9 9,9 2.25,0 4.31,-0.83 5.89,-2.2l2.2,2.2 1.27,-1.27L3.89,3.27l-0.97,-0.98zM16.47,18.39C15.26,19.39 13.7,20 12,20c-3.87,0 -7,-3.13 -7,-7 0,-1.7 0.61,-3.26 1.61,-4.47l9.86,9.86zM8.02,3.28L6.6,1.86l-0.86,0.71 1.42,1.42 0.86,-0.71z"
android:fillColor="#FFFFFFFF" />
</vector>
@@ -0,0 +1,11 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Material Symbols "flag", white so its user tints it. -->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:pathData="M14.4,6L14,4H5v17h2v-7h5.6l0.4,2h7V6z"
android:fillColor="#FFFFFFFF" />
</vector>
@@ -0,0 +1,11 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Material Symbols "pause", white so its user tints it. -->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:pathData="M6,19h4V5H6v14zM14,5v14h4V5h-4z"
android:fillColor="#FFFFFFFF" />
</vector>
@@ -0,0 +1,11 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Material Symbols "play_arrow", white so its user tints it. -->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:pathData="M8,5v14l11,-7z"
android:fillColor="#FFFFFFFF" />
</vector>
@@ -0,0 +1,11 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Material Symbols "replay", white so its user tints it. -->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:pathData="M12,5V1L7,6l5,5V7c3.31,0 6,2.69 6,6s-2.69,6 -6,6 -6,-2.69 -6,-6H4c0,4.42 3.58,8 8,8s8,-3.58 8,-8 -3.58,-8 -8,-8z"
android:fillColor="#FFFFFFFF" />
</vector>
@@ -0,0 +1,11 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Material Symbols "stop", white so its user tints it. -->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:pathData="M6,6h12v12H6z"
android:fillColor="#FFFFFFFF" />
</vector>
@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- `MaterialShapes.Cookie12Sided` (`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_cookie"
android:fillColor="#FFFFFFFF" />
</vector>
@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- `MaterialShapes.Cookie6Sided` (`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_scallop"
android:fillColor="#FFFFFFFF" />
</vector>
@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- `MaterialShapes.Square` (`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_square"
android:fillColor="#FFFFFFFF" />
</vector>
@@ -0,0 +1,66 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Day face: the numeral face with a day-of-week pill in place of the 3 —
the same stack as `widget_analog_plain.xml`. The pill is a plain
`TextClock` holding only a day pattern, set by
`ui/widget/AnalogClockWidgetContent.kt` (which also sizes everything to
the widget), so it re-renders at midnight with no app-side ticking. -->
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent">
<ImageView
android:id="@+id/widget_analog_dial"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:importantForAccessibility="no"
android:scaleType="fitCenter"
android:src="@drawable/widget_analog_dial_round" />
<TextView
android:id="@+id/widget_analog_numeral_12"
style="@style/WidgetAnalogNumeral"
android:layout_gravity="top|center_horizontal"
android:text="12" />
<TextView
android:id="@+id/widget_analog_numeral_6"
style="@style/WidgetAnalogNumeral"
android:layout_gravity="bottom|center_horizontal"
android:text="6" />
<TextView
android:id="@+id/widget_analog_numeral_9"
style="@style/WidgetAnalogNumeral"
android:layout_gravity="start|center_vertical"
android:text="9" />
<!-- Wrapper only so its end padding can inset the pill from the dial's
edge in proportion to the widget (`setViewPadding` works on every
API level, layout margins only from 31). -->
<FrameLayout
android:id="@+id/widget_analog_badge_frame"
android:layout_width="match_parent"
android:layout_height="match_parent">
<TextClock
android:id="@+id/widget_analog_badge_day"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center_vertical|end"
android:background="@drawable/widget_badge_pill_background"
android:includeFontPadding="false"
android:textColor="@color/widget_analog_badge_pill_text"
android:textStyle="bold"
android:textSize="12sp" />
</FrameLayout>
<AnalogClock
android:id="@+id/widget_clock_analog"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:dial="@drawable/widget_analog_dial_none"
android:hand_hour="@drawable/widget_analog_hour_hand"
android:hand_minute="@drawable/widget_analog_minute_hand" />
</FrameLayout>
@@ -0,0 +1,51 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Numeral face: the dial plus four oversized hour numerals inside it, under
the hands — the same stack as `widget_analog_plain.xml`. The numerals'
text size and edge inset are set from
`ui/widget/AnalogClockWidgetContent.kt` in proportion to the widget's
size; fixed sp/dp here would not scale with the dial. -->
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent">
<ImageView
android:id="@+id/widget_analog_dial"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:importantForAccessibility="no"
android:scaleType="fitCenter"
android:src="@drawable/widget_analog_dial_round" />
<TextView
android:id="@+id/widget_analog_numeral_12"
style="@style/WidgetAnalogNumeral"
android:layout_gravity="top|center_horizontal"
android:text="12" />
<TextView
android:id="@+id/widget_analog_numeral_6"
style="@style/WidgetAnalogNumeral"
android:layout_gravity="bottom|center_horizontal"
android:text="6" />
<TextView
android:id="@+id/widget_analog_numeral_9"
style="@style/WidgetAnalogNumeral"
android:layout_gravity="start|center_vertical"
android:text="9" />
<TextView
android:id="@+id/widget_analog_numeral_3"
style="@style/WidgetAnalogNumeral"
android:layout_gravity="end|center_vertical"
android:text="3" />
<AnalogClock
android:id="@+id/widget_clock_analog"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:dial="@drawable/widget_analog_dial_none"
android:hand_hour="@drawable/widget_analog_hour_hand"
android:hand_minute="@drawable/widget_analog_minute_hand" />
</FrameLayout>
@@ -0,0 +1,42 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Plain analog face. Every analog layout is the same stack: the dial
`ImageView` (its shape drawable is set per style by
`ui/widget/AnalogClockWidgetContent.kt`, and fitCenter scales it up or
down to the widget), then any overlays, then an `AnalogClock` with an
invisible dial that only draws the hands. `AnalogClock` alone never
scales its drawables up — see `widget_analog_hour_hand.xml`. -->
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent">
<ImageView
android:id="@+id/widget_analog_dial"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:importantForAccessibility="no"
android:scaleType="fitCenter"
android:src="@drawable/widget_analog_dial_round" />
<!-- The date that orbits opposite the seconds dot on dated faces. Its own
invisible clock — every drawable transparent except the seconds hand,
which is the date — because one hand can carry only one tint, and
the date (neutral) and the dot (accent) need two. Under the main
clock so the hands sweep over it; gone unless a face shows it. -->
<AnalogClock
android:id="@+id/widget_clock_analog_date"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:visibility="gone"
android:dial="@drawable/widget_analog_dial_none"
android:hand_hour="@drawable/widget_analog_dial_none"
android:hand_minute="@drawable/widget_analog_dial_none" />
<AnalogClock
android:id="@+id/widget_clock_analog"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:dial="@drawable/widget_analog_dial_none"
android:hand_hour="@drawable/widget_analog_hour_hand"
android:hand_minute="@drawable/widget_analog_minute_hand" />
</FrameLayout>
@@ -1,13 +1,17 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
Embedded inside a Glance layout via AndroidRemoteViews (widget/TimerWidget.kt):
Glance has no chronometer composable, and RemoteViews.setChronometer +
setChronometerCountDown is exactly the mechanism TimerNotifications.kt
already uses for the same reason — the platform ticks it, not the app.
A running timer's or stopwatch's readout, embedded in their Glance layouts
via AndroidRemoteViews (ui/widget/WidgetKit.kt): Glance has no chronometer
composable, and RemoteViews.setChronometer is exactly the mechanism
TimerNotifications.kt already uses for the same reason — the platform
ticks it, not the app. Its text size is set to fit the widget at runtime.
-->
<Chronometer xmlns:android="http://schemas.android.com/apk/res/android"
android:id="@+id/widget_chronometer"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textColor="?android:attr/textColorPrimary"
android:fontFamily="sans-serif-medium"
android:includeFontPadding="false"
android:maxLines="1"
android:textColor="@color/widget_on_surface"
android:textSize="28sp" />
@@ -0,0 +1,15 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
The quick-timer widget's countdown (ui/widget/QuickTimerWidgetContent.kt):
`widget_chronometer.xml` in the colour that reads on its primary-container
shape.
-->
<Chronometer xmlns:android="http://schemas.android.com/apk/res/android"
android:id="@+id/widget_chronometer_on_primary"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:fontFamily="sans-serif-medium"
android:includeFontPadding="false"
android:maxLines="1"
android:textColor="@color/widget_on_primary_container"
android:textSize="28sp" />
@@ -0,0 +1,42 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Bold digital face: a black-weight time on a primary-container card. Every digital face is one of these `RemoteViews` layouts,
filled in and sized to the widget by `ui/widget/DigitalFaces.kt` — the
same builder the config picker's previews inflate. The time and date are
`TextClock`s, so they tick in the launcher with no app-side updates. -->
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:id="@+id/widget_digital_root"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="@drawable/widget_digital_card_primary"
android:gravity="center"
android:orientation="vertical">
<TextClock
android:id="@+id/widget_digital_time"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:fontFamily="sans-serif-black"
android:includeFontPadding="false"
android:maxLines="1"
android:textColor="@color/widget_on_primary_container"
android:textSize="48sp" />
<TextClock
android:id="@+id/widget_digital_date"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:fontFamily="sans-serif-medium"
android:includeFontPadding="false"
android:maxLines="1"
android:textColor="@color/widget_on_primary_container"
android:textSize="14sp" />
<!-- World-clock rows (`widget_digital_city*.xml`), added at runtime. -->
<LinearLayout
android:id="@+id/widget_digital_cities"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:visibility="gone" />
</LinearLayout>
@@ -0,0 +1,32 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- One world-clock row on the surface card: the city, then its own `TextClock`
(its zone set at runtime). Sized by `ui/widget/DigitalFaces.kt`. -->
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:gravity="center_vertical"
android:orientation="horizontal">
<TextView
android:id="@+id/widget_digital_city_name"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:ellipsize="end"
android:fontFamily="sans-serif-medium"
android:includeFontPadding="false"
android:maxLines="1"
android:textColor="@color/widget_on_surface_variant"
android:textSize="14sp" />
<TextClock
android:id="@+id/widget_digital_city_time"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:fontFamily="sans-serif-medium"
android:includeFontPadding="false"
android:maxLines="1"
android:textColor="@color/widget_on_surface"
android:textSize="14sp" />
</LinearLayout>
@@ -0,0 +1,32 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- One world-clock row on the Bold face's primary card: the city, then its own `TextClock`
(its zone set at runtime). Sized by `ui/widget/DigitalFaces.kt`. -->
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:gravity="center_vertical"
android:orientation="horizontal">
<TextView
android:id="@+id/widget_digital_city_name"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:ellipsize="end"
android:fontFamily="sans-serif-medium"
android:includeFontPadding="false"
android:maxLines="1"
android:textColor="@color/widget_on_primary_container"
android:textSize="14sp" />
<TextClock
android:id="@+id/widget_digital_city_time"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:fontFamily="sans-serif-medium"
android:includeFontPadding="false"
android:maxLines="1"
android:textColor="@color/widget_on_primary_container"
android:textSize="14sp" />
</LinearLayout>
@@ -0,0 +1,42 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Classic digital face: medium time and an accent date on the shared surface card. Every digital face is one of these `RemoteViews` layouts,
filled in and sized to the widget by `ui/widget/DigitalFaces.kt` — the
same builder the config picker's previews inflate. The time and date are
`TextClock`s, so they tick in the launcher with no app-side updates. -->
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:id="@+id/widget_digital_root"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="@drawable/widget_digital_card_surface"
android:gravity="center"
android:orientation="vertical">
<TextClock
android:id="@+id/widget_digital_time"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:fontFamily="sans-serif-medium"
android:includeFontPadding="false"
android:maxLines="1"
android:textColor="@color/widget_on_surface"
android:textSize="48sp" />
<TextClock
android:id="@+id/widget_digital_date"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:fontFamily="sans-serif-medium"
android:includeFontPadding="false"
android:maxLines="1"
android:textColor="@color/widget_primary"
android:textSize="14sp" />
<!-- World-clock rows (`widget_digital_city*.xml`), added at runtime. -->
<LinearLayout
android:id="@+id/widget_digital_cities"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:visibility="gone" />
</LinearLayout>
@@ -0,0 +1,44 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Pill digital face: the time in a primary-container pill and the date in
a smaller tertiary one under it, floating on the home screen with no card
— see `widget_digital_classic.xml` for how every digital face is built. -->
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:gravity="center"
android:orientation="vertical">
<TextClock
android:id="@+id/widget_digital_time"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:fontFamily="sans-serif-medium"
android:includeFontPadding="false"
android:maxLines="1"
android:textColor="@color/widget_on_primary_container"
android:textSize="48sp"
android:background="@drawable/widget_digital_pill_primary"
android:textStyle="bold" />
<!-- Wrapper only so its top padding can space the pills in proportion to
the widget (`setViewPadding` works on every API level, layout
margins only from 31). -->
<FrameLayout
android:id="@+id/widget_digital_date_frame"
android:layout_width="wrap_content"
android:layout_height="wrap_content">
<TextClock
android:id="@+id/widget_digital_date"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:fontFamily="sans-serif-medium"
android:includeFontPadding="false"
android:maxLines="1"
android:textColor="@color/widget_on_tertiary_container"
android:textSize="14sp"
android:background="@drawable/widget_digital_pill_tertiary" />
</FrameLayout>
</LinearLayout>
@@ -0,0 +1,55 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Stacked digital face: hours over minutes, black weight, on an M3 Square
floating on the home screen like the analog dials — the shape is a
fitCenter `ImageView`, so it stays square whatever the widget's aspect.
See `widget_digital_classic.xml` for how every digital face is built. -->
<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_digital_shape_square" />
<LinearLayout
android:layout_width="match_parent"
android:layout_height="match_parent"
android:gravity="center"
android:orientation="vertical">
<TextClock
android:id="@+id/widget_digital_hours"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:fontFamily="sans-serif-black"
android:includeFontPadding="false"
android:maxLines="1"
android:textColor="@color/widget_on_tertiary_container"
android:textSize="48sp" />
<TextClock
android:id="@+id/widget_digital_minutes"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:fontFamily="sans-serif-black"
android:includeFontPadding="false"
android:maxLines="1"
android:textColor="@color/widget_tertiary"
android:textSize="48sp" />
<TextClock
android:id="@+id/widget_digital_date"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:fontFamily="sans-serif-medium"
android:includeFontPadding="false"
android:maxLines="1"
android:textColor="@color/widget_on_tertiary_container"
android:textSize="14sp" />
</LinearLayout>
</FrameLayout>
@@ -0,0 +1,42 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Thin digital face: a thin, accent-coloured time on the shared surface card. Every digital face is one of these `RemoteViews` layouts,
filled in and sized to the widget by `ui/widget/DigitalFaces.kt` — the
same builder the config picker's previews inflate. The time and date are
`TextClock`s, so they tick in the launcher with no app-side updates. -->
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:id="@+id/widget_digital_root"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="@drawable/widget_digital_card_surface"
android:gravity="center"
android:orientation="vertical">
<TextClock
android:id="@+id/widget_digital_time"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:fontFamily="sans-serif-thin"
android:includeFontPadding="false"
android:maxLines="1"
android:textColor="@color/widget_primary"
android:textSize="48sp" />
<TextClock
android:id="@+id/widget_digital_date"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:fontFamily="sans-serif-medium"
android:includeFontPadding="false"
android:maxLines="1"
android:textColor="@color/widget_on_surface_variant"
android:textSize="14sp" />
<!-- World-clock rows (`widget_digital_city*.xml`), added at runtime. -->
<LinearLayout
android:id="@+id/widget_digital_cities"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:visibility="gone" />
</LinearLayout>
@@ -0,0 +1,17 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
A stopped timer's or stopwatch's readout: a plain TextView styled exactly
like `widget_chronometer.xml` (no font padding), so a readout measures the
same running or not and the layout around it doesn't jump or overflow
when it switches. Text, size and colour are set at runtime
(ui/widget/WidgetKit.kt).
-->
<TextView xmlns:android="http://schemas.android.com/apk/res/android"
android:id="@+id/widget_readout"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:fontFamily="sans-serif-medium"
android:includeFontPadding="false"
android:maxLines="1"
android:textColor="@color/widget_on_surface"
android:textSize="28sp" />
@@ -1,13 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
Embedded inside a Glance layout via AndroidRemoteViews (widget/ClockWidget.kt):
a self-updating platform clock that honours the device's 12/24-hour
setting on its own, with no per-second app code. The timezone is set at
render time via RemoteViews.setString(id, "setTimeZone", zoneId).
-->
<TextClock xmlns:android="http://schemas.android.com/apk/res/android"
android:id="@+id/widget_text_clock"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textColor="?android:attr/textColorPrimary"
android:textSize="28sp" />
@@ -0,0 +1,24 @@
<resources>
<!-- Dark-mode tones of `values-v31/colors.xml`'s Material You palette. -->
<color name="widget_analog_dial_fill">@android:color/system_accent2_800</color>
<color name="widget_analog_dial_primary">@android:color/system_accent1_800</color>
<color name="widget_analog_dial_tertiary">@android:color/system_accent3_800</color>
<color name="widget_analog_hand_hour">@android:color/system_accent1_100</color>
<color name="widget_analog_hand_minute">@android:color/system_accent1_300</color>
<color name="widget_analog_numeral">@android:color/system_accent1_200</color>
<color name="widget_analog_second">@android:color/system_accent3_300</color>
<color name="widget_analog_date">@android:color/system_neutral1_50</color>
<color name="widget_analog_badge_pill">@android:color/system_accent3_300</color>
<color name="widget_analog_badge_pill_text">@android:color/system_accent3_900</color>
<!-- Dark tones of the shared widget palette in `values-v31/colors.xml`. -->
<color name="widget_surface">@android:color/system_accent2_800</color>
<color name="widget_on_surface">@android:color/system_neutral1_50</color>
<color name="widget_on_surface_variant">@android:color/system_neutral2_200</color>
<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_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>
</resources>
+26
View File
@@ -0,0 +1,26 @@
<resources>
<!-- Dark-mode counterparts of the analog widget's colours — see the light
`values/colors.xml` for why these are literal, resource-qualified
colours rather than `?android:attr/...`. -->
<color name="widget_analog_dial_fill">#FF2B2F26</color>
<color name="widget_analog_dial_primary">#FF3E4C32</color>
<color name="widget_analog_dial_tertiary">#FF334B47</color>
<color name="widget_analog_hand_hour">#FFE4E9E0</color>
<color name="widget_analog_hand_minute">#FFB4C7A4</color>
<color name="widget_analog_numeral">#FFC9D9B9</color>
<color name="widget_analog_second">#FFA5CEC7</color>
<color name="widget_analog_date">#FFE3E3DC</color>
<color name="widget_analog_badge_pill">#FFB4C7A4</color>
<color name="widget_analog_badge_pill_text">#FF22261C</color>
<!-- Dark tones of the shared widget palette in `values/colors.xml`. -->
<color name="widget_surface">#FF2B2F26</color>
<color name="widget_on_surface">#FFE4E9E0</color>
<color name="widget_on_surface_variant">#FFC4C8BA</color>
<color name="widget_primary">#FFCEE2BA</color>
<color name="widget_primary_container">#FF4A583B</color>
<color name="widget_on_primary_container">#FFE8F6D9</color>
<color name="widget_tertiary">#FFA5CEC7</color>
<color name="widget_tertiary_container">#FF244D48</color>
<color name="widget_on_tertiary_container">#FFC1ECE4</color>
</resources>
+28
View File
@@ -0,0 +1,28 @@
<resources>
<!-- Analog widget on API 31+: Material You system palette, so the dial
follows the wallpaper like the launcher's own widgets. These
`@android:color/system_*` tones are resolved by the launcher, and
`values-night-v31/` swaps the tones for dark mode. -->
<color name="widget_analog_dial_fill">@android:color/system_accent2_50</color>
<color name="widget_analog_dial_primary">@android:color/system_accent1_100</color>
<color name="widget_analog_dial_tertiary">@android:color/system_accent3_100</color>
<color name="widget_analog_hand_hour">@android:color/system_accent1_700</color>
<color name="widget_analog_hand_minute">@android:color/system_accent1_400</color>
<color name="widget_analog_numeral">@android:color/system_accent1_600</color>
<color name="widget_analog_second">@android:color/system_accent3_500</color>
<color name="widget_analog_date">@android:color/system_neutral1_900</color>
<color name="widget_analog_badge_pill">@android:color/system_accent3_200</color>
<color name="widget_analog_badge_pill_text">@android:color/system_accent3_900</color>
<!-- The shared widget palette on API 31+: Material You system tones,
mapped onto the M3 roles as the platform's own dynamic scheme maps them. -->
<color name="widget_surface">@android:color/system_accent2_50</color>
<color name="widget_on_surface">@android:color/system_neutral1_900</color>
<color name="widget_on_surface_variant">@android:color/system_neutral2_700</color>
<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_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>
</resources>
+4
View File
@@ -0,0 +1,4 @@
<resources>
<!-- The launcher's own widget corner, so a card's drawn corners match its clip. -->
<dimen name="widget_corner_radius">@android:dimen/system_app_widget_background_radius</dimen>
</resources>
+33
View File
@@ -4,4 +4,37 @@
<color name="seed">#FF6B7A5C</color>
<!-- Adaptive icon background -->
<color name="ic_launcher_background">#FF6B7A5C</color>
<!-- Analog widget dial/hands/badge (light). `?android:attr/colorControlHighlight`
was tried first and dropped: it is a low-contrast pressed-state overlay
colour, not meant to stand alone as an opaque fill, and read as a
near-black smudge regardless of the launcher's day/night. These are
resolved against the host process's own configuration (`values-night/`
below), the same mechanism the platform itself uses for day/night —
not a `?android:attr`, which proved unreliable here. -->
<color name="widget_analog_dial_fill">#FFE8EAE1</color>
<color name="widget_analog_dial_primary">#FFD7E8C6</color>
<color name="widget_analog_dial_tertiary">#FFCCE8E3</color>
<color name="widget_analog_hand_hour">#FF44483E</color>
<color name="widget_analog_hand_minute">#FF6B7A5C</color>
<color name="widget_analog_numeral">#FF4E5C40</color>
<color name="widget_analog_second">#FF3D6660</color>
<color name="widget_analog_date">#FF1A1C18</color>
<color name="widget_analog_badge_pill">#FF6B7A5C</color>
<color name="widget_analog_badge_pill_text">#FFFFFFFF</color>
<!-- Every other widget's palette (light), named after the M3 roles they
stand in for. Like the analog colours above, resource-qualified
rather than `?android:attr/...` or a Glance theme, so the launcher
resolves them against its own day/night (`values-night/`), and on
API 31+ against the wallpaper (`values-v31/`). -->
<color name="widget_surface">#FFE8EAE1</color>
<color name="widget_on_surface">#FF1C1F1A</color>
<color name="widget_on_surface_variant">#FF44483E</color>
<color name="widget_primary">#FF5C6A4E</color>
<color name="widget_primary_container">#FFD7E8C6</color>
<color name="widget_on_primary_container">#FF1D2A10</color>
<color name="widget_tertiary">#FF3D6660</color>
<color name="widget_tertiary_container">#FFCCE8E3</color>
<color name="widget_on_tertiary_container">#FF05201C</color>
</resources>
+4
View File
@@ -0,0 +1,4 @@
<resources>
<!-- Widget card corners below API 31, where there is no system widget radius. -->
<dimen name="widget_corner_radius">20dp</dimen>
</resources>
+35 -1
View File
@@ -348,6 +348,40 @@
<string name="widget_next_alarm_today">Today</string>
<string name="widget_timer_description">Timer</string>
<string name="widget_timer_new">New timer</string>
<string name="widget_timer_expired">Time\'s up</string>
<string name="widget_timer_expired">Done</string>
<string name="widget_clock_description">Clock</string>
<!-- M11: digital clock widget faces + per-instance config -->
<string name="widget_config_title">Digital clock</string>
<string name="widget_config_style">Style</string>
<string name="clock_face_regular">Classic</string>
<string name="clock_face_bold">Bold</string>
<string name="clock_face_light">Thin</string>
<string name="clock_face_pill">Pill</string>
<string name="clock_face_stacked">Stacked</string>
<string name="widget_config_show_date">Show date</string>
<string name="widget_config_show_world_clocks">World clocks</string>
<string name="widget_config_show_world_clocks_hint">Up to three cities from your World clock tab</string>
<!-- M11: analog clock widget -->
<string name="widget_analog_description">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>
<string name="analog_style_badge">Day</string>
<string name="analog_style_cookie">Cookie</string>
<string name="analog_style_clover">Clover</string>
<string name="analog_style_petal">Petal</string>
<string name="analog_style_sunny">Sunny</string>
<string name="analog_style_square">Square</string>
<string name="analog_style_scallop">Scallop</string>
<!-- M11: stopwatch widget -->
<string name="widget_stopwatch_description">Stopwatch</string>
<string name="widget_stopwatch_laps">Laps: %1$d</string>
<!-- M11: quick-timer widget -->
<string name="widget_quick_timer_description">Quick timer</string>
<string name="widget_quick_timer_config_title">Quick timer</string>
<string name="widget_quick_timer_start">Start %1$s</string>
</resources>
+12
View File
@@ -8,4 +8,16 @@
<!-- The exported AlarmClock door has no content view: translucent and
title-less so nothing flashes on the way through (M9 D3). -->
<style name="Theme.Clockula.Invisible" parent="android:Theme.Translucent.NoTitleBar" />
<!-- An hour numeral on the analog widget's dial (`layout/widget_analog_*.xml`).
Its text size and inset are overridden per widget size at runtime. -->
<style name="WidgetAnalogNumeral">
<item name="android:layout_width">wrap_content</item>
<item name="android:layout_height">wrap_content</item>
<item name="android:includeFontPadding">false</item>
<item name="android:textColor">@color/widget_analog_numeral</item>
<item name="android:fontFamily">sans-serif-medium</item>
<item name="android:textStyle">bold</item>
<item name="android:textSize">18sp</item>
</style>
</resources>
+20
View File
@@ -0,0 +1,20 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- M3 Expressive `MaterialShapes` outlines as vector path data in a 100x100
viewport at 2..98: each generated once from the library's own
`RoundedPolygon` (normalized cubics), so they are the real shapes, not
lookalikes. Shared by the analog dials and every other widget's shape
drawables (`drawable/widget_shape_*.xml`) through `android:pathData`. -->
<resources>
<!-- MaterialShapes.Cookie12Sided -->
<string name="widget_shape_path_cookie" translatable="false">M50.00,2.49 C52.46,2.49 54.92,3.48 56.72,5.46 C59.11,8.09 62.82,9.08 66.20,8.00 C71.30,6.38 76.70,9.49 77.84,14.72 C78.60,18.19 81.31,20.90 84.78,21.66 C90.01,22.81 93.13,28.20 91.50,33.30 C90.42,36.69 91.42,40.39 94.04,42.78 C98.00,46.39 98.00,52.62 94.04,56.23 C91.42,58.62 90.42,62.32 91.50,65.71 C93.13,70.81 90.01,76.20 84.78,77.35 C81.31,78.11 78.60,80.82 77.84,84.29 C76.70,89.52 71.30,92.63 66.20,91.01 C62.82,89.93 59.11,90.92 56.72,93.55 C53.11,97.51 46.89,97.51 43.28,93.55 C40.89,90.92 37.18,89.93 33.80,91.01 C28.70,92.63 23.30,89.52 22.16,84.29 C21.40,80.82 18.69,78.11 15.22,77.35 C9.99,76.20 6.87,70.81 8.50,65.71 C9.58,62.32 8.58,58.62 5.96,56.23 C2.00,52.62 2.00,46.39 5.96,42.78 C8.58,40.39 9.58,36.69 8.50,33.30 C6.87,28.20 9.99,22.81 15.22,21.66 C18.69,20.90 21.40,18.19 22.16,14.72 C23.30,9.49 28.70,6.38 33.80,8.00 C37.18,9.08 40.89,8.09 43.28,5.46 C45.08,3.48 47.54,2.49 50.00,2.49 Z</string>
<!-- MaterialShapes.Sunny -->
<string name="widget_shape_path_sunny" translatable="false">M97.70,50.00 C97.70,51.72 97.11,53.45 95.92,54.84 C93.49,57.69 91.06,60.54 88.63,63.39 C87.61,64.59 87.00,66.07 86.87,67.64 C86.58,71.37 86.28,75.10 85.98,78.84 C85.69,82.49 82.79,85.39 79.14,85.68 C75.40,85.98 71.67,86.28 67.94,86.58 C66.37,86.70 64.88,87.32 63.69,88.34 C60.84,90.76 57.99,93.19 55.14,95.62 C52.35,98.00 48.25,98.00 45.46,95.62 C42.61,93.19 39.76,90.76 36.91,88.34 C35.71,87.32 34.22,86.70 32.66,86.58 C28.93,86.28 25.19,85.98 21.46,85.68 C17.81,85.39 14.91,82.49 14.61,78.84 C14.32,75.10 14.02,71.37 13.72,67.64 C13.60,66.07 12.98,64.59 11.96,63.39 C9.53,60.54 7.10,57.69 4.67,54.84 C2.30,52.05 2.30,47.95 4.67,45.16 C7.10,42.31 9.53,39.46 11.96,36.61 C12.98,35.41 13.60,33.93 13.72,32.36 C14.02,28.63 14.32,24.90 14.61,21.16 C14.91,17.51 17.81,14.61 21.46,14.32 C25.19,14.02 28.93,13.72 32.66,13.42 C34.22,13.30 35.71,12.68 36.91,11.66 C39.76,9.24 42.61,6.81 45.46,4.38 C48.25,2.00 52.35,2.00 55.14,4.38 C57.99,6.81 60.84,9.24 63.69,11.66 C64.88,12.68 66.37,13.30 67.94,13.42 C71.67,13.72 75.40,14.02 79.14,14.32 C82.79,14.61 85.69,17.51 85.98,21.16 C86.28,24.90 86.58,28.63 86.87,32.36 C87.00,33.93 87.61,35.41 88.63,36.61 C91.06,39.46 93.49,42.31 95.92,45.16 C97.11,46.55 97.70,48.28 97.70,50.00 Z</string>
<!-- MaterialShapes.Cookie6Sided -->
<string name="widget_shape_path_scallop" translatable="false">M70.79,85.80 C69.17,86.73 67.66,87.86 66.30,89.17 C66.26,89.22 66.21,89.26 66.16,89.31 C57.15,98.00 42.87,97.99 33.87,89.28 C31.16,86.66 27.85,84.75 24.23,83.70 C24.17,83.69 24.10,83.67 24.04,83.65 C12.00,80.19 4.87,67.82 7.92,55.67 C8.83,52.01 8.83,48.19 7.93,44.53 C7.91,44.47 7.89,44.40 7.88,44.34 C4.86,32.19 12.01,19.83 24.05,16.39 C27.67,15.35 30.98,13.44 33.70,10.83 C33.74,10.78 33.79,10.74 33.84,10.69 C42.85,2.00 57.13,2.01 66.13,10.72 C68.84,13.34 72.15,15.25 75.77,16.30 C75.83,16.31 75.90,16.33 75.96,16.35 C88.00,19.81 95.13,32.18 92.08,44.33 C91.17,47.99 91.17,51.81 92.07,55.47 C92.09,55.53 92.11,55.60 92.12,55.66 C95.14,67.81 87.99,80.17 75.95,83.61 C74.14,84.13 72.41,84.86 70.79,85.80 Z</string>
<!-- MaterialShapes.Square -->
<string name="widget_shape_path_square" translatable="false">M89.56,89.56 C84.35,94.78 77.15,98.00 69.20,98.00 C56.40,98.00 43.60,98.00 30.80,98.00 C14.89,98.00 2.00,85.11 2.00,69.20 C2.00,56.40 2.00,43.60 2.00,30.80 C2.00,14.89 14.89,2.00 30.80,2.00 C43.60,2.00 56.40,2.00 69.20,2.00 C85.11,2.00 98.00,14.89 98.00,30.80 C98.00,43.60 98.00,56.40 98.00,69.20 C98.00,77.15 94.78,84.35 89.56,89.56 Z</string>
<!-- MaterialShapes.Clover8Leaf -->
<string name="widget_shape_path_petal" translatable="false">M50.00,8.84 C50.70,8.47 51.39,8.10 52.09,7.73 C62.69,2.11 75.76,7.87 78.74,19.49 C78.86,19.96 78.98,20.43 79.10,20.90 C79.86,21.13 80.61,21.36 81.37,21.59 C92.84,25.11 98.00,38.42 91.89,48.75 C91.65,49.17 91.40,49.58 91.16,50.00 C91.53,50.70 91.90,51.39 92.27,52.09 C97.89,62.69 92.13,75.76 80.51,78.74 C80.04,78.86 79.57,78.98 79.10,79.10 C78.87,79.86 78.64,80.61 78.41,81.37 C74.89,92.84 61.58,98.00 51.25,91.89 C50.83,91.65 50.42,91.40 50.00,91.16 C49.30,91.53 48.61,91.90 47.91,92.27 C37.31,97.89 24.24,92.13 21.26,80.51 C21.14,80.04 21.02,79.57 20.90,79.10 C20.14,78.87 19.39,78.64 18.63,78.41 C7.16,74.89 2.00,61.58 8.11,51.25 C8.35,50.83 8.60,50.42 8.84,50.00 C8.47,49.30 8.10,48.61 7.73,47.91 C2.11,37.31 7.87,24.24 19.49,21.26 C19.96,21.14 20.43,21.02 20.90,20.90 C21.13,20.14 21.36,19.39 21.59,18.63 C25.11,7.16 38.42,2.00 48.75,8.11 C49.17,8.35 49.58,8.60 50.00,8.84 Z</string>
<!-- MaterialShapes.Clover4Leaf -->
<string name="widget_shape_path_clover" translatable="false">M50.00,11.42 C50.46,11.06 50.93,10.71 51.39,10.35 C62.25,2.00 77.62,3.00 87.31,12.69 C97.00,22.38 98.00,37.75 89.65,48.61 C89.29,49.07 88.94,49.54 88.58,50.00 C88.94,50.46 89.29,50.93 89.65,51.39 C98.00,62.25 97.00,77.62 87.31,87.31 C77.62,97.00 62.25,98.00 51.39,89.65 C50.93,89.29 50.46,88.94 50.00,88.58 C49.54,88.94 49.07,89.29 48.61,89.65 C37.75,98.00 22.38,97.00 12.69,87.31 C3.00,77.62 2.00,62.25 10.35,51.39 C10.71,50.93 11.06,50.46 11.42,50.00 C11.06,49.54 10.71,49.07 10.35,48.61 C2.00,37.75 3.00,22.38 12.69,12.69 C22.38,3.00 37.75,2.00 48.61,10.35 C49.07,10.71 49.54,11.06 50.00,11.42 Z</string>
</resources>
@@ -0,0 +1,15 @@
<?xml version="1.0" encoding="utf-8"?>
<appwidget-provider xmlns:android="http://schemas.android.com/apk/res/android"
android:minWidth="110dp"
android:minHeight="110dp"
android:minResizeWidth="110dp"
android:minResizeHeight="110dp"
android:targetCellWidth="2"
android:targetCellHeight="2"
android:updatePeriodMillis="1800000"
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:previewLayout="@layout/widget_analog_plain" />
+4 -2
View File
@@ -9,5 +9,7 @@
android:updatePeriodMillis="0"
android:resizeMode="horizontal|vertical"
android:widgetCategory="home_screen"
android:previewImage="@mipmap/ic_launcher"
android:description="@string/widget_clock_description" />
android:description="@string/widget_clock_description"
android:configure="de.jeanlucmakiola.clockula.ui.widget.config.ClockWidgetConfigActivity"
android:widgetFeatures="reconfigurable|configuration_optional"
android:previewLayout="@layout/widget_digital_classic" />
@@ -0,0 +1,15 @@
<?xml version="1.0" encoding="utf-8"?>
<appwidget-provider xmlns:android="http://schemas.android.com/apk/res/android"
android:minWidth="40dp"
android:minHeight="40dp"
android:minResizeWidth="40dp"
android:minResizeHeight="40dp"
android:targetCellWidth="1"
android:targetCellHeight="1"
android:updatePeriodMillis="0"
android:resizeMode="horizontal|vertical"
android:widgetCategory="home_screen"
android:previewImage="@mipmap/ic_launcher"
android:description="@string/widget_quick_timer_description"
android:configure="de.jeanlucmakiola.clockula.ui.widget.config.QuickTimerConfigActivity"
android:widgetFeatures="reconfigurable|configuration_optional" />
@@ -0,0 +1,13 @@
<?xml version="1.0" encoding="utf-8"?>
<appwidget-provider xmlns:android="http://schemas.android.com/apk/res/android"
android:minWidth="140dp"
android:minHeight="80dp"
android:minResizeWidth="110dp"
android:minResizeHeight="60dp"
android:targetCellWidth="3"
android:targetCellHeight="2"
android:updatePeriodMillis="0"
android:resizeMode="horizontal|vertical"
android:widgetCategory="home_screen"
android:previewImage="@mipmap/ic_launcher"
android:description="@string/widget_stopwatch_description" />
@@ -31,7 +31,7 @@ class ManifestRulesTest {
}
@Test
fun `exactly the launcher, the door and the three widget receivers are exported`() {
fun `exactly the launcher, the door, the three 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 }
@@ -39,20 +39,53 @@ class ManifestRulesTest {
.map { Regex("android:name=\"([^\"]+)\"").find(it)!!.groupValues[1] }
.toList()
// M10 adds exactly three: a GlanceAppWidgetReceiver must be exported
// for the platform to pick it as a widget provider at all, and
// `the three widget receivers answer only APPWIDGET_UPDATE` pins that
// each one is guarded to that one platform-delivered broadcast and
// nothing wider.
// A GlanceAppWidgetReceiver must be exported for the platform to pick
// 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 —
// only the launcher can start an `APPWIDGET_CONFIGURE` activity
// cross-package — and guarded to that one action by `the three
// widget configurators answer only APPWIDGET_CONFIGURE` below.
assertThat(exported).containsExactly(
".MainActivity",
".interop.AlarmClockActivity",
".ui.widget.config.ClockWidgetConfigActivity",
".ui.widget.config.AnalogClockConfigActivity",
".ui.widget.config.QuickTimerConfigActivity",
".widget.NextAlarmWidgetReceiver",
".widget.TimerWidgetReceiver",
".widget.ClockWidgetReceiver",
".widget.AnalogClockWidgetReceiver",
".widget.StopwatchWidgetReceiver",
".widget.QuickTimerWidgetReceiver",
)
}
/**
* M11: the mirror of `the six widget receivers answer only
* APPWIDGET_UPDATE` for the three 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`() {
val configurators = listOf(
".ui.widget.config.ClockWidgetConfigActivity",
".ui.widget.config.AnalogClockConfigActivity",
".ui.widget.config.QuickTimerConfigActivity",
)
for (name in configurators) {
val block = activityBlock(name)
val actions = Regex("<action\\s+android:name=\"([^\"]+)\"").findAll(block)
.map { it.groupValues[1] }
.toList()
assertThat(actions).containsExactly("android.appwidget.action.APPWIDGET_CONFIGURE")
assertThat(block).doesNotContain("android:permission")
}
}
@Test
fun `the door demands the caller's own alarm permission`() {
assertThat(activityBlock(".interop.AlarmClockActivity"))
@@ -96,11 +129,14 @@ class ManifestRulesTest {
}
@Test
fun `every receiver but the three widget providers is still unexported`() {
fun `every receiver but the six widget providers is still unexported`() {
val widgetReceivers = setOf(
".widget.NextAlarmWidgetReceiver",
".widget.TimerWidgetReceiver",
".widget.ClockWidgetReceiver",
".widget.AnalogClockWidgetReceiver",
".widget.StopwatchWidgetReceiver",
".widget.QuickTimerWidgetReceiver",
)
val exportedReceivers = Regex("<receiver\\b[\\s\\S]*?(?:/>|</receiver>)")
.findAll(text)
@@ -113,17 +149,20 @@ class ManifestRulesTest {
}
/**
* M10: a `GlanceAppWidgetReceiver` must be exported for the platform to
* offer it as a widget provider, but that is the *only* door it may open —
* each one answers `APPWIDGET_UPDATE` and nothing else, so being exported
* A `GlanceAppWidgetReceiver` must be exported for the platform to offer
* it as a widget provider, but that is the *only* door it may open — each
* one answers `APPWIDGET_UPDATE` and nothing else, so being exported
* cannot be widened into a second, wider surface by accident.
*/
@Test
fun `the three widget receivers answer only APPWIDGET_UPDATE`() {
fun `the six widget receivers answer only APPWIDGET_UPDATE`() {
val widgetReceivers = listOf(
".widget.NextAlarmWidgetReceiver",
".widget.TimerWidgetReceiver",
".widget.ClockWidgetReceiver",
".widget.AnalogClockWidgetReceiver",
".widget.StopwatchWidgetReceiver",
".widget.QuickTimerWidgetReceiver",
)
for (name in widgetReceivers) {
val block = Regex("<receiver\\b[\\s\\S]*?(?:/>|</receiver>)")
@@ -0,0 +1,65 @@
package de.jeanlucmakiola.clockula.domain.widget
import com.google.common.truth.Truth.assertThat
import org.junit.jupiter.api.Test
class ClockWidgetSettingsTest {
@Test
fun `null parses to Regular`() {
assertThat(parseDigitalClockStyle(null)).isEqualTo(DigitalClockStyle.REGULAR)
}
@Test
fun `an unknown name parses to Regular`() {
assertThat(parseDigitalClockStyle("Nonexistent")).isEqualTo(DigitalClockStyle.REGULAR)
}
@Test
fun `each digital style's own name round-trips`() {
DigitalClockStyle.entries.forEach { style ->
assertThat(parseDigitalClockStyle(style.name)).isEqualTo(style)
}
}
@Test
fun `digital settings default to Regular with the date on and world clocks off`() {
val settings = DigitalClockSettings()
assertThat(settings.style).isEqualTo(DigitalClockStyle.REGULAR)
assertThat(settings.showDate).isTrue()
assertThat(settings.showWorldClocks).isFalse()
}
@Test
fun `null parses to Round`() {
assertThat(parseAnalogClockStyle(null)).isEqualTo(AnalogClockStyle.ROUND)
}
@Test
fun `an unknown analog name parses to Round`() {
assertThat(parseAnalogClockStyle("Nonexistent")).isEqualTo(AnalogClockStyle.ROUND)
}
@Test
fun `each analog style's own name round-trips`() {
AnalogClockStyle.entries.forEach { style ->
assertThat(parseAnalogClockStyle(style.name)).isEqualTo(style)
}
}
@Test
fun `null quick-timer duration parses to the default`() {
assertThat(parseQuickTimerDuration(null)).isEqualTo(QuickTimerWidgetSettings.DEFAULT)
}
@Test
fun `an out-of-range quick-timer duration parses to the default`() {
assertThat(parseQuickTimerDuration(0L)).isEqualTo(QuickTimerWidgetSettings.DEFAULT)
}
@Test
fun `an in-range quick-timer duration round-trips`() {
assertThat(parseQuickTimerDuration(600_000L).inWholeMilliseconds).isEqualTo(600_000L)
}
}

Some files were not shown because too many files have changed in this diff Show More