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:
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
+42
@@ -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()
|
||||
+142
@@ -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
|
||||
+232
@@ -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
|
||||
+105
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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" />
|
||||
@@ -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
Reference in New Issue
Block a user