fix(widgets): config saves, live world clocks, quick-timer guards
- Config screens share WidgetConfigActivity: a picker that dismisses right after selecting no longer finishes with RESULT_CANCELED (which removed a newly placed widget on API 29/30), and the save runs to completion. - Digital config shows only once stored settings are read, so an early OK or a late load can't write defaults over them. - Digital widget collects world clocks inside the composition, so edits reach a live session. - Quick timer: a second tap while its timer is active starts nothing; durations are capped at 59:59 (1 h preset dropped) to fit the readout. - Analog date hand is redrawn at local midnight and on time/zone changes (AnalogDateRefresh); updatePeriodMillis back to 0. - WidgetSync routes each flow only to the widgets that read it. - Stopwatch widget uses the ElapsedRealtimeClock seam and has a two-row minimum height. - Remove dead quick-timer helpers; share showWorldClockIntent.
This commit is contained in:
@@ -284,8 +284,9 @@
|
|||||||
app's widget: APPWIDGET_UPDATE is a platform-delivered broadcast,
|
app's widget: APPWIDGET_UPDATE is a platform-delivered broadcast,
|
||||||
never a door another app can open by naming the action itself.
|
never a door another app can open by naming the action itself.
|
||||||
updatePeriodMillis is 0 in every provider-info XML — WidgetSync
|
updatePeriodMillis is 0 in every provider-info XML — WidgetSync
|
||||||
is the one refresh trigger (the quick-timer widget has no live
|
is the one refresh trigger for the data widgets, and the analog
|
||||||
state, so it is refreshed only by its own config save). -->
|
widget's date is redrawn at midnight and on a clock or zone
|
||||||
|
change (widget/AnalogDateRefresh.kt). -->
|
||||||
<receiver
|
<receiver
|
||||||
android:name=".widget.NextAlarmWidgetReceiver"
|
android:name=".widget.NextAlarmWidgetReceiver"
|
||||||
android:exported="true">
|
android:exported="true">
|
||||||
|
|||||||
@@ -2,10 +2,13 @@ package de.jeanlucmakiola.clockula.alarm.receiver
|
|||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
|
import androidx.glance.appwidget.updateAll
|
||||||
import dagger.hilt.android.AndroidEntryPoint
|
import dagger.hilt.android.AndroidEntryPoint
|
||||||
import de.jeanlucmakiola.clockula.alarm.AlarmEngine
|
import de.jeanlucmakiola.clockula.alarm.AlarmEngine
|
||||||
import de.jeanlucmakiola.clockula.stopwatch.StopwatchEngine
|
import de.jeanlucmakiola.clockula.stopwatch.StopwatchEngine
|
||||||
import de.jeanlucmakiola.clockula.timer.TimerEngine
|
import de.jeanlucmakiola.clockula.timer.TimerEngine
|
||||||
|
import de.jeanlucmakiola.clockula.widget.AnalogClockWidget
|
||||||
|
import de.jeanlucmakiola.clockula.widget.AnalogDateRefresh
|
||||||
import de.jeanlucmakiola.floret.di.ApplicationScope
|
import de.jeanlucmakiola.floret.di.ApplicationScope
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
@@ -79,6 +82,10 @@ class SystemEventReceiver : HiltBroadcastReceiver() {
|
|||||||
// the notification re-posts with a freshly derived
|
// the notification re-posts with a freshly derived
|
||||||
// chronometer base (M7 D4, D17).
|
// chronometer base (M7 D4, D17).
|
||||||
stopwatchEngine.onSystemTimeChanged()
|
stopwatchEngine.onSystemTimeChanged()
|
||||||
|
// The analog faces' date is drawn for the old day or
|
||||||
|
// zone, and the midnight redraw armed for the old one.
|
||||||
|
AnalogClockWidget().updateAll(context)
|
||||||
|
AnalogDateRefresh.schedule(context)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
+15
-7
@@ -5,19 +5,27 @@ import de.jeanlucmakiola.clockula.domain.timer.TimerPresets
|
|||||||
import kotlin.time.Duration
|
import kotlin.time.Duration
|
||||||
import kotlin.time.Duration.Companion.milliseconds
|
import kotlin.time.Duration.Companion.milliseconds
|
||||||
import kotlin.time.Duration.Companion.minutes
|
import kotlin.time.Duration.Companion.minutes
|
||||||
|
import kotlin.time.Duration.Companion.seconds
|
||||||
import kotlin.time.Instant
|
import kotlin.time.Instant
|
||||||
|
|
||||||
/** One quick-timer widget instance's own pick: the duration it starts on tap. */
|
/** The quick-timer widget's one setting: the duration it starts on tap. */
|
||||||
data class QuickTimerWidgetSettings(val duration: Duration = DEFAULT) {
|
object QuickTimerWidgetSettings {
|
||||||
companion object {
|
val DEFAULT: Duration = 5.minutes
|
||||||
val DEFAULT: Duration = 5.minutes
|
|
||||||
}
|
/**
|
||||||
|
* 59:59 — the Scallop's readout is sized for "MM:SS", so the widget never
|
||||||
|
* offers (or reads back) anything that would format as "H:MM:SS".
|
||||||
|
*/
|
||||||
|
val MAX: Duration = 59.minutes + 59.seconds
|
||||||
|
|
||||||
|
/** What the config screen offers: the Timers screen's own built-in quick starts, up to [MAX]. */
|
||||||
|
val OPTIONS: List<Duration> = TimerPresets.BUILT_IN.filter { it <= MAX }
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Anything unreadable, or out of [TimerPresets]' own sane range, falls back to [QuickTimerWidgetSettings.DEFAULT]. */
|
/** Anything unreadable, or outside [TimerPresets.MIN]..[QuickTimerWidgetSettings.MAX], falls back to [QuickTimerWidgetSettings.DEFAULT]. */
|
||||||
fun parseQuickTimerDuration(raw: Long?): Duration {
|
fun parseQuickTimerDuration(raw: Long?): Duration {
|
||||||
val duration = raw?.milliseconds
|
val duration = raw?.milliseconds
|
||||||
return duration?.takeIf { it in TimerPresets.MIN..TimerPresets.MAX } ?: QuickTimerWidgetSettings.DEFAULT
|
return duration?.takeIf { it in TimerPresets.MIN..QuickTimerWidgetSettings.MAX } ?: QuickTimerWidgetSettings.DEFAULT
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -3,19 +3,26 @@ package de.jeanlucmakiola.clockula.timer.receiver
|
|||||||
import android.appwidget.AppWidgetManager
|
import android.appwidget.AppWidgetManager
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
|
import androidx.glance.GlanceId
|
||||||
import androidx.glance.appwidget.GlanceAppWidgetManager
|
import androidx.glance.appwidget.GlanceAppWidgetManager
|
||||||
|
import androidx.glance.appwidget.state.getAppWidgetState
|
||||||
import androidx.glance.appwidget.state.updateAppWidgetState
|
import androidx.glance.appwidget.state.updateAppWidgetState
|
||||||
|
import androidx.glance.state.PreferencesGlanceStateDefinition
|
||||||
import dagger.hilt.android.AndroidEntryPoint
|
import dagger.hilt.android.AndroidEntryPoint
|
||||||
import de.jeanlucmakiola.clockula.alarm.receiver.HiltBroadcastReceiver
|
import de.jeanlucmakiola.clockula.alarm.receiver.HiltBroadcastReceiver
|
||||||
import de.jeanlucmakiola.clockula.data.timers.TimerRepository
|
import de.jeanlucmakiola.clockula.data.timers.TimerRepository
|
||||||
import de.jeanlucmakiola.clockula.domain.TimerDraft
|
import de.jeanlucmakiola.clockula.domain.TimerDraft
|
||||||
|
import de.jeanlucmakiola.clockula.domain.TimerState
|
||||||
import de.jeanlucmakiola.clockula.timer.QuickTimerIntents
|
import de.jeanlucmakiola.clockula.timer.QuickTimerIntents
|
||||||
import de.jeanlucmakiola.clockula.timer.TimerEngine
|
import de.jeanlucmakiola.clockula.timer.TimerEngine
|
||||||
import de.jeanlucmakiola.clockula.widget.QuickTimerWidget
|
import de.jeanlucmakiola.clockula.widget.QuickTimerWidget
|
||||||
import de.jeanlucmakiola.clockula.widget.WIDGET_QUICK_TIMER_ID_KEY
|
import de.jeanlucmakiola.clockula.widget.WIDGET_QUICK_TIMER_ID_KEY
|
||||||
import de.jeanlucmakiola.floret.di.ApplicationScope
|
import de.jeanlucmakiola.floret.di.ApplicationScope
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.flow.first
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.sync.Mutex
|
||||||
|
import kotlinx.coroutines.sync.withLock
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
import kotlin.time.Duration.Companion.milliseconds
|
import kotlin.time.Duration.Companion.milliseconds
|
||||||
|
|
||||||
@@ -47,20 +54,39 @@ class QuickTimerReceiver : HiltBroadcastReceiver() {
|
|||||||
val pendingResult = goAsync()
|
val pendingResult = goAsync()
|
||||||
scope.launch {
|
scope.launch {
|
||||||
try {
|
try {
|
||||||
// Transient: the widget's own reset/stop deletes it rather
|
// Serialised, so a double tap's second start sees the first
|
||||||
// than leaving a stray idle timer in the Timers list.
|
// one's timer instead of racing it.
|
||||||
val id = timers.create(TimerDraft(duration = durationMillis.milliseconds, deleteAfterUse = true))
|
startMutex.withLock {
|
||||||
engine.start(id)
|
val glanceId = runCatching { GlanceAppWidgetManager(context).getGlanceIdBy(appWidgetId) }.getOrNull()
|
||||||
// Remember which timer this widget started, so it shows that
|
// A second tap before the widget re-rendered into its
|
||||||
// one's countdown and controls (`ui/widget/QuickTimerWidgetContent.kt`).
|
// running state: this widget already has a live timer,
|
||||||
val glanceId = runCatching { GlanceAppWidgetManager(context).getGlanceIdBy(appWidgetId) }.getOrNull()
|
// so leave it be rather than start one it can't control.
|
||||||
if (glanceId != null) {
|
if (glanceId != null && hasActiveTimer(context, glanceId)) return@withLock
|
||||||
updateAppWidgetState(context, glanceId) { prefs -> prefs[WIDGET_QUICK_TIMER_ID_KEY] = id }
|
// Transient: the widget's own reset/stop deletes it rather
|
||||||
QuickTimerWidget().update(context, glanceId)
|
// 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`).
|
||||||
|
if (glanceId != null) {
|
||||||
|
updateAppWidgetState(context, glanceId) { prefs -> prefs[WIDGET_QUICK_TIMER_ID_KEY] = id }
|
||||||
|
QuickTimerWidget().update(context, glanceId)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
pendingResult.finish()
|
pendingResult.finish()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Whether the timer this widget last started still exists and isn't idle — the same test the widget's at-rest state uses. */
|
||||||
|
private suspend fun hasActiveTimer(context: Context, glanceId: GlanceId): Boolean {
|
||||||
|
val state = getAppWidgetState(context, PreferencesGlanceStateDefinition, glanceId)
|
||||||
|
val id = state[WIDGET_QUICK_TIMER_ID_KEY] ?: return false
|
||||||
|
return timers.timers().first().any { it.id == id && it.state != TimerState.IDLE }
|
||||||
|
}
|
||||||
|
|
||||||
|
private companion object {
|
||||||
|
val startMutex = Mutex()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
package de.jeanlucmakiola.clockula.ui.widget
|
package de.jeanlucmakiola.clockula.ui.widget
|
||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.content.Intent
|
|
||||||
import android.graphics.Bitmap
|
import android.graphics.Bitmap
|
||||||
import android.graphics.Canvas
|
import android.graphics.Canvas
|
||||||
import android.graphics.Paint
|
import android.graphics.Paint
|
||||||
@@ -26,11 +25,9 @@ import androidx.glance.layout.Alignment
|
|||||||
import androidx.glance.layout.Box
|
import androidx.glance.layout.Box
|
||||||
import androidx.glance.layout.fillMaxSize
|
import androidx.glance.layout.fillMaxSize
|
||||||
import androidx.glance.layout.size
|
import androidx.glance.layout.size
|
||||||
import de.jeanlucmakiola.clockula.MainActivity
|
|
||||||
import de.jeanlucmakiola.clockula.R
|
import de.jeanlucmakiola.clockula.R
|
||||||
import de.jeanlucmakiola.clockula.domain.widget.AnalogClockStyle
|
import de.jeanlucmakiola.clockula.domain.widget.AnalogClockStyle
|
||||||
import de.jeanlucmakiola.clockula.domain.widget.parseAnalogClockStyle
|
import de.jeanlucmakiola.clockula.domain.widget.parseAnalogClockStyle
|
||||||
import de.jeanlucmakiola.clockula.ui.shell.ShellNavigation
|
|
||||||
import de.jeanlucmakiola.clockula.widget.WIDGET_ANALOG_STYLE_KEY
|
import de.jeanlucmakiola.clockula.widget.WIDGET_ANALOG_STYLE_KEY
|
||||||
|
|
||||||
/** Reads this instance's own [AnalogClockStyle] reactively, then renders [AnalogClockWidgetContent]. */
|
/** Reads this instance's own [AnalogClockStyle] reactively, then renders [AnalogClockWidgetContent]. */
|
||||||
@@ -211,8 +208,8 @@ internal fun analogClockViews(context: Context, style: AnalogClockStyle, sideDp:
|
|||||||
* opposite the seconds dot, on the layout's separate date clock. Text cannot
|
* 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
|
* 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.
|
* `setSecondHandTintList` in the launcher so it still follows day/night.
|
||||||
* The date goes stale at midnight until the next update; the provider's 30
|
* It is redrawn at midnight and on a clock or zone change
|
||||||
* minute `updatePeriodMillis` bounds that.
|
* (`widget/AnalogDateRefresh.kt`).
|
||||||
*/
|
*/
|
||||||
private fun dateHand(context: Context): Bitmap {
|
private fun dateHand(context: Context): Bitmap {
|
||||||
val px = 360
|
val px = 360
|
||||||
@@ -239,8 +236,3 @@ private fun dateHand(context: Context): Bitmap {
|
|||||||
canvas.drawText(date, 50 * unit, dateY - (metrics.ascent + metrics.descent) / 2, paint)
|
canvas.drawText(date, 50 * unit, dateY - (metrics.ascent + metrics.descent) / 2, paint)
|
||||||
return bitmap
|
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)
|
|
||||||
|
|||||||
@@ -1,10 +1,9 @@
|
|||||||
package de.jeanlucmakiola.clockula.ui.widget
|
package de.jeanlucmakiola.clockula.ui.widget
|
||||||
|
|
||||||
import android.content.Context
|
|
||||||
import android.content.Intent
|
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.collectAsState
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.produceState
|
import androidx.compose.runtime.remember
|
||||||
import androidx.datastore.preferences.core.Preferences
|
import androidx.datastore.preferences.core.Preferences
|
||||||
import androidx.glance.GlanceModifier
|
import androidx.glance.GlanceModifier
|
||||||
import androidx.glance.LocalContext
|
import androidx.glance.LocalContext
|
||||||
@@ -15,51 +14,56 @@ import androidx.glance.appwidget.action.actionStartActivity
|
|||||||
import androidx.glance.currentState
|
import androidx.glance.currentState
|
||||||
import androidx.glance.layout.Box
|
import androidx.glance.layout.Box
|
||||||
import androidx.glance.layout.fillMaxSize
|
import androidx.glance.layout.fillMaxSize
|
||||||
import de.jeanlucmakiola.clockula.MainActivity
|
|
||||||
import de.jeanlucmakiola.clockula.data.worldclocks.WorldClockRepository
|
import de.jeanlucmakiola.clockula.data.worldclocks.WorldClockRepository
|
||||||
import de.jeanlucmakiola.clockula.data.zones.ZoneNames
|
import de.jeanlucmakiola.clockula.data.zones.ZoneNames
|
||||||
import de.jeanlucmakiola.clockula.domain.widget.ClockWidgetPresenter
|
import de.jeanlucmakiola.clockula.domain.widget.ClockWidgetPresenter
|
||||||
import de.jeanlucmakiola.clockula.domain.widget.DigitalClockSettings
|
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_DIGITAL_STYLE_KEY
|
||||||
import de.jeanlucmakiola.clockula.widget.WIDGET_SHOW_DATE_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.WIDGET_SHOW_WORLD_CLOCKS_KEY
|
||||||
import de.jeanlucmakiola.clockula.widget.di.WidgetEntryPoint
|
import de.jeanlucmakiola.clockula.widget.di.WidgetEntryPoint
|
||||||
import de.jeanlucmakiola.clockula.widget.parseDigitalClockSettings
|
import de.jeanlucmakiola.clockula.widget.parseDigitalClockSettings
|
||||||
|
import kotlinx.coroutines.flow.Flow
|
||||||
import kotlinx.coroutines.flow.first
|
import kotlinx.coroutines.flow.first
|
||||||
|
import kotlinx.coroutines.flow.map
|
||||||
|
|
||||||
/** One resolved world clock for the digital 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)
|
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. */
|
/** The world clocks the digital widget shows, resolved to city names, as they change. */
|
||||||
internal suspend fun widgetCities(worldClocks: WorldClockRepository, zoneNames: ZoneNames): List<ClockWidgetCity> =
|
internal fun widgetCitiesFlow(worldClocks: WorldClockRepository, zoneNames: ZoneNames): Flow<List<ClockWidgetCity>> =
|
||||||
ClockWidgetPresenter.present(worldClocks.worldClocks().first()).map { clock ->
|
worldClocks.worldClocks().map { clocks ->
|
||||||
ClockWidgetCity(
|
ClockWidgetPresenter.present(clocks).map { clock ->
|
||||||
zoneId = clock.zoneId,
|
ClockWidgetCity(
|
||||||
cityName = clock.label?.takeIf(String::isNotBlank) ?: zoneNames.cityOf(clock.zoneId) ?: clock.zoneId,
|
zoneId = clock.zoneId,
|
||||||
)
|
cityName = clock.label?.takeIf(String::isNotBlank) ?: zoneNames.cityOf(clock.zoneId) ?: clock.zoneId,
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** [widgetCitiesFlow]'s current value — for the config preview and a session's first frame. */
|
||||||
|
internal suspend fun widgetCities(worldClocks: WorldClockRepository, zoneNames: ZoneNames): List<ClockWidgetCity> =
|
||||||
|
widgetCitiesFlow(worldClocks, zoneNames).first()
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Reads this widget instance's own state — reactively, so a config-screen
|
* Reads this widget instance's own state and the world clocks — both
|
||||||
* save that lands on a still-live Glance session (see `widget/ClockWidget.kt`)
|
* reactively, so a config-screen save or a world-clock edit that lands on a
|
||||||
* recomposes with the new values instead of the ones the session started
|
* still-live Glance session (see `widget/ClockWidget.kt`) recomposes with the
|
||||||
* with — then renders [ClockWidgetContent].
|
* new values instead of the ones the session started with — then renders
|
||||||
|
* [ClockWidgetContent]. [initialCities] only makes the first frame right.
|
||||||
*/
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
fun ClockWidgetRoot(entryPoint: WidgetEntryPoint) {
|
fun ClockWidgetRoot(entryPoint: WidgetEntryPoint, initialCities: List<ClockWidgetCity>) {
|
||||||
val prefs = currentState<Preferences>()
|
val prefs = currentState<Preferences>()
|
||||||
val settings = parseDigitalClockSettings(
|
val settings = parseDigitalClockSettings(
|
||||||
style = prefs[WIDGET_DIGITAL_STYLE_KEY],
|
style = prefs[WIDGET_DIGITAL_STYLE_KEY],
|
||||||
showDate = prefs[WIDGET_SHOW_DATE_KEY],
|
showDate = prefs[WIDGET_SHOW_DATE_KEY],
|
||||||
showWorldClocks = prefs[WIDGET_SHOW_WORLD_CLOCKS_KEY],
|
showWorldClocks = prefs[WIDGET_SHOW_WORLD_CLOCKS_KEY],
|
||||||
)
|
)
|
||||||
val showWorldClocks = settings.showWorldClocks
|
val cities by remember { widgetCitiesFlow(entryPoint.worldClockRepository(), entryPoint.zoneNames()) }
|
||||||
val cities by produceState(initialValue = emptyList<ClockWidgetCity>(), showWorldClocks) {
|
.collectAsState(initial = initialCities)
|
||||||
value = if (showWorldClocks) widgetCities(entryPoint.worldClockRepository(), entryPoint.zoneNames()) else emptyList()
|
|
||||||
}
|
|
||||||
|
|
||||||
ClockWidgetContent(cities = cities, settings = settings)
|
ClockWidgetContent(cities = if (settings.showWorldClocks) cities else emptyList(), settings = settings)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -80,8 +84,3 @@ fun ClockWidgetContent(cities: List<ClockWidgetCity>, settings: DigitalClockSett
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun showWorldClockIntent(context: Context): Intent =
|
|
||||||
Intent(context, MainActivity::class.java)
|
|
||||||
.setAction(ShellNavigation.ACTION_SHOW_WORLD_CLOCK)
|
|
||||||
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
|
||||||
|
|||||||
@@ -142,7 +142,9 @@ private fun Running(context: Context, item: TimerWidgetItem, elapsedRealtimeNow:
|
|||||||
/**
|
/**
|
||||||
* The one layout both states share: [icon] over a time readout, sized for
|
* 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
|
* 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%.
|
* keeps its size from rest to running and fits the shape's inner ~60%. The
|
||||||
|
* duration is capped at 59:59 (`QuickTimerWidgetSettings.MAX`), so a time
|
||||||
|
* never needs more.
|
||||||
*/
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
private fun GlyphOverTime(
|
private fun GlyphOverTime(
|
||||||
|
|||||||
@@ -28,7 +28,6 @@ import de.jeanlucmakiola.clockula.domain.format.ClockFormat
|
|||||||
import de.jeanlucmakiola.clockula.domain.widget.StopwatchWidgetModel
|
import de.jeanlucmakiola.clockula.domain.widget.StopwatchWidgetModel
|
||||||
import de.jeanlucmakiola.clockula.stopwatch.StopwatchIntents
|
import de.jeanlucmakiola.clockula.stopwatch.StopwatchIntents
|
||||||
import de.jeanlucmakiola.clockula.stopwatch.receiver.StopwatchActionReceiver
|
import de.jeanlucmakiola.clockula.stopwatch.receiver.StopwatchActionReceiver
|
||||||
import kotlin.time.Duration.Companion.milliseconds
|
|
||||||
import kotlinx.coroutines.flow.map
|
import kotlinx.coroutines.flow.map
|
||||||
import de.jeanlucmakiola.clockula.widget.di.WidgetEntryPoint
|
import de.jeanlucmakiola.clockula.widget.di.WidgetEntryPoint
|
||||||
import de.jeanlucmakiola.clockula.domain.widget.StopwatchWidgetPresenter
|
import de.jeanlucmakiola.clockula.domain.widget.StopwatchWidgetPresenter
|
||||||
@@ -36,7 +35,6 @@ import de.jeanlucmakiola.clockula.domain.StopwatchRun
|
|||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.collectAsState
|
import androidx.compose.runtime.collectAsState
|
||||||
import android.os.SystemClock
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Reads the stopwatch run and its laps reactively, then renders [StopwatchWidgetContent].
|
* Reads the stopwatch run and its laps reactively, then renders [StopwatchWidgetContent].
|
||||||
@@ -52,9 +50,9 @@ fun StopwatchWidgetRoot(entryPoint: WidgetEntryPoint, initialRun: StopwatchRun,
|
|||||||
val lapCount by remember { repository.laps().map { it.size } }.collectAsState(initial = initialLapCount)
|
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
|
// The same instant the model is resolved at, so the chronometer's base
|
||||||
// is exactly `now - elapsed`.
|
// is exactly `now - elapsed`.
|
||||||
val elapsedRealtime = remember(run, lapCount) { SystemClock.elapsedRealtime() }
|
val elapsedRealtime = remember(run, lapCount) { entryPoint.elapsedRealtimeClock().elapsedRealtime() }
|
||||||
val model = remember(run, lapCount) { StopwatchWidgetPresenter.present(run, elapsedRealtime.milliseconds, lapCount) }
|
val model = remember(run, lapCount) { StopwatchWidgetPresenter.present(run, elapsedRealtime, lapCount) }
|
||||||
StopwatchWidgetContent(model, elapsedRealtime)
|
StopwatchWidgetContent(model, elapsedRealtime.inWholeMilliseconds)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -117,7 +115,7 @@ fun StopwatchWidgetContent(model: StopwatchWidgetModel, elapsedRealtimeNow: Long
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Under this height (one launcher row, give or take) the controls sit beside the readout. */
|
/** Under this height the controls sit beside the readout. */
|
||||||
private const val WIDE_BELOW_HEIGHT = 120f
|
private const val WIDE_BELOW_HEIGHT = 120f
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package de.jeanlucmakiola.clockula.ui.widget
|
package de.jeanlucmakiola.clockula.ui.widget
|
||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
import android.os.Build
|
import android.os.Build
|
||||||
import android.util.TypedValue
|
import android.util.TypedValue
|
||||||
import android.widget.RemoteViews
|
import android.widget.RemoteViews
|
||||||
@@ -30,9 +31,11 @@ import androidx.glance.layout.fillMaxSize
|
|||||||
import androidx.glance.layout.size
|
import androidx.glance.layout.size
|
||||||
import androidx.glance.layout.wrapContentSize
|
import androidx.glance.layout.wrapContentSize
|
||||||
import androidx.glance.unit.ColorProvider
|
import androidx.glance.unit.ColorProvider
|
||||||
|
import de.jeanlucmakiola.clockula.MainActivity
|
||||||
import de.jeanlucmakiola.clockula.R
|
import de.jeanlucmakiola.clockula.R
|
||||||
import de.jeanlucmakiola.clockula.ui.theme.ClockulaDarkFallback
|
import de.jeanlucmakiola.clockula.ui.theme.ClockulaDarkFallback
|
||||||
import de.jeanlucmakiola.clockula.ui.theme.ClockulaLightFallback
|
import de.jeanlucmakiola.clockula.ui.theme.ClockulaLightFallback
|
||||||
|
import de.jeanlucmakiola.clockula.ui.shell.ShellNavigation
|
||||||
import de.jeanlucmakiola.floret.glance.FloretGlanceTheme
|
import de.jeanlucmakiola.floret.glance.FloretGlanceTheme
|
||||||
import de.jeanlucmakiola.floret.glance.WidgetThemeMode
|
import de.jeanlucmakiola.floret.glance.WidgetThemeMode
|
||||||
import java.util.Locale
|
import java.util.Locale
|
||||||
@@ -181,5 +184,11 @@ internal fun CenteredSquare(side: Dp, onClick: Action, content: @Composable () -
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Opens the app on World clock — where both clock widgets lead. */
|
||||||
|
internal fun showWorldClockIntent(context: Context): Intent =
|
||||||
|
Intent(context, MainActivity::class.java)
|
||||||
|
.setAction(ShellNavigation.ACTION_SHOW_WORLD_CLOCK)
|
||||||
|
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||||
|
|
||||||
internal fun currentLocale(context: Context): Locale =
|
internal fun currentLocale(context: Context): Locale =
|
||||||
ConfigurationCompat.getLocales(context.resources.configuration).get(0) ?: Locale.getDefault()
|
ConfigurationCompat.getLocales(context.resources.configuration).get(0) ?: Locale.getDefault()
|
||||||
|
|||||||
+24
-73
@@ -1,20 +1,9 @@
|
|||||||
package de.jeanlucmakiola.clockula.ui.widget.config
|
package de.jeanlucmakiola.clockula.ui.widget.config
|
||||||
|
|
||||||
import android.appwidget.AppWidgetManager
|
|
||||||
import android.content.Intent
|
|
||||||
import android.os.Bundle
|
|
||||||
import android.widget.FrameLayout
|
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.foundation.layout.size
|
||||||
import androidx.compose.material3.MaterialTheme
|
|
||||||
import androidx.compose.material3.Surface
|
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.collectAsState
|
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
@@ -24,24 +13,16 @@ import androidx.compose.ui.platform.LocalContext
|
|||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.viewinterop.AndroidView
|
import androidx.compose.ui.viewinterop.AndroidView
|
||||||
import androidx.glance.appwidget.GlanceAppWidgetManager
|
|
||||||
import androidx.glance.appwidget.state.getAppWidgetState
|
import androidx.glance.appwidget.state.getAppWidgetState
|
||||||
import androidx.glance.appwidget.state.updateAppWidgetState
|
import androidx.glance.appwidget.state.updateAppWidgetState
|
||||||
import androidx.glance.state.PreferencesGlanceStateDefinition
|
import androidx.glance.state.PreferencesGlanceStateDefinition
|
||||||
import androidx.lifecycle.lifecycleScope
|
|
||||||
import dagger.hilt.android.AndroidEntryPoint
|
import dagger.hilt.android.AndroidEntryPoint
|
||||||
import de.jeanlucmakiola.clockula.R
|
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.AnalogClockStyle
|
||||||
import de.jeanlucmakiola.clockula.domain.widget.parseAnalogClockStyle
|
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.ui.widget.analogClockViews
|
||||||
import de.jeanlucmakiola.clockula.widget.AnalogClockWidget
|
import de.jeanlucmakiola.clockula.widget.AnalogClockWidget
|
||||||
import de.jeanlucmakiola.clockula.widget.WIDGET_ANALOG_STYLE_KEY
|
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
|
* The analog widget's entire configuration: a style pick, nothing else — this
|
||||||
@@ -49,63 +30,33 @@ import javax.inject.Inject
|
|||||||
* straight away, as the reference picker does (no separate "Save" step).
|
* straight away, as the reference picker does (no separate "Save" step).
|
||||||
*/
|
*/
|
||||||
@AndroidEntryPoint
|
@AndroidEntryPoint
|
||||||
class AnalogClockConfigActivity : ComponentActivity() {
|
class AnalogClockConfigActivity : WidgetConfigActivity() {
|
||||||
|
|
||||||
@Inject lateinit var settingsPrefs: SettingsPrefs
|
@Composable
|
||||||
|
override fun Content() {
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
var style by remember { mutableStateOf(AnalogClockStyle.ROUND) }
|
||||||
super.onCreate(savedInstanceState)
|
LaunchedEffect(glanceId) {
|
||||||
val appWidgetId = intent?.getIntExtra(AppWidgetManager.EXTRA_APPWIDGET_ID, AppWidgetManager.INVALID_APPWIDGET_ID)
|
val state = getAppWidgetState(this@AnalogClockConfigActivity, PreferencesGlanceStateDefinition, glanceId)
|
||||||
?: AppWidgetManager.INVALID_APPWIDGET_ID
|
style = parseAnalogClockStyle(state[WIDGET_ANALOG_STYLE_KEY])
|
||||||
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() },
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
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
|
||||||
|
save {
|
||||||
|
updateAppWidgetState(this@AnalogClockConfigActivity, glanceId) { prefs ->
|
||||||
|
prefs[WIDGET_ANALOG_STYLE_KEY] = picked.name
|
||||||
|
}
|
||||||
|
AnalogClockWidget().update(this@AnalogClockConfigActivity, glanceId)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onDismiss = ::dismiss,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun resultIntent(appWidgetId: Int) =
|
|
||||||
Intent().putExtra(AppWidgetManager.EXTRA_APPWIDGET_ID, appWidgetId)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
internal fun analogStyleLabel(style: AnalogClockStyle): Int = when (style) {
|
internal fun analogStyleLabel(style: AnalogClockStyle): Int = when (style) {
|
||||||
|
|||||||
+34
-82
@@ -1,28 +1,17 @@
|
|||||||
package de.jeanlucmakiola.clockula.ui.widget.config
|
package de.jeanlucmakiola.clockula.ui.widget.config
|
||||||
|
|
||||||
import android.appwidget.AppWidgetManager
|
|
||||||
import android.content.Intent
|
|
||||||
import android.os.Bundle
|
|
||||||
import android.widget.FrameLayout
|
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.Box
|
||||||
import androidx.compose.foundation.layout.Spacer
|
import androidx.compose.foundation.layout.Spacer
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.height
|
import androidx.compose.foundation.layout.height
|
||||||
import androidx.compose.foundation.layout.size
|
import androidx.compose.foundation.layout.size
|
||||||
import androidx.compose.foundation.layout.padding
|
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.Switch
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.material3.TextButton
|
import androidx.compose.material3.TextButton
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.collectAsState
|
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.produceState
|
import androidx.compose.runtime.produceState
|
||||||
@@ -34,20 +23,16 @@ import androidx.compose.ui.platform.LocalContext
|
|||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.viewinterop.AndroidView
|
import androidx.compose.ui.viewinterop.AndroidView
|
||||||
import androidx.glance.appwidget.GlanceAppWidgetManager
|
|
||||||
import androidx.glance.appwidget.state.getAppWidgetState
|
import androidx.glance.appwidget.state.getAppWidgetState
|
||||||
import androidx.glance.appwidget.state.updateAppWidgetState
|
import androidx.glance.appwidget.state.updateAppWidgetState
|
||||||
import androidx.glance.state.PreferencesGlanceStateDefinition
|
import androidx.glance.state.PreferencesGlanceStateDefinition
|
||||||
import androidx.lifecycle.lifecycleScope
|
|
||||||
import dagger.hilt.android.AndroidEntryPoint
|
import dagger.hilt.android.AndroidEntryPoint
|
||||||
import de.jeanlucmakiola.clockula.R
|
import de.jeanlucmakiola.clockula.R
|
||||||
import de.jeanlucmakiola.clockula.data.prefs.SettingsPrefs
|
|
||||||
import de.jeanlucmakiola.clockula.data.worldclocks.WorldClockRepository
|
import de.jeanlucmakiola.clockula.data.worldclocks.WorldClockRepository
|
||||||
import de.jeanlucmakiola.clockula.data.zones.ZoneNames
|
import de.jeanlucmakiola.clockula.data.zones.ZoneNames
|
||||||
import de.jeanlucmakiola.clockula.domain.widget.DigitalClockSettings
|
import de.jeanlucmakiola.clockula.domain.widget.DigitalClockSettings
|
||||||
import de.jeanlucmakiola.clockula.domain.widget.DigitalClockStyle
|
import de.jeanlucmakiola.clockula.domain.widget.DigitalClockStyle
|
||||||
import de.jeanlucmakiola.clockula.domain.widget.showsWorldClocks
|
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.ClockWidgetCity
|
||||||
import de.jeanlucmakiola.clockula.ui.widget.digitalClockViews
|
import de.jeanlucmakiola.clockula.ui.widget.digitalClockViews
|
||||||
import de.jeanlucmakiola.clockula.ui.widget.widgetCities
|
import de.jeanlucmakiola.clockula.ui.widget.widgetCities
|
||||||
@@ -59,83 +44,50 @@ import de.jeanlucmakiola.clockula.widget.parseDigitalClockSettings
|
|||||||
import de.jeanlucmakiola.floret.components.FullScreenPicker
|
import de.jeanlucmakiola.floret.components.FullScreenPicker
|
||||||
import de.jeanlucmakiola.floret.components.GroupedRow
|
import de.jeanlucmakiola.floret.components.GroupedRow
|
||||||
import de.jeanlucmakiola.floret.components.Position
|
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
|
import javax.inject.Inject
|
||||||
|
|
||||||
/**
|
/** Picks the digital widget instance's style and the two switches. */
|
||||||
* 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
|
@AndroidEntryPoint
|
||||||
class ClockWidgetConfigActivity : ComponentActivity() {
|
class ClockWidgetConfigActivity : WidgetConfigActivity() {
|
||||||
|
|
||||||
@Inject lateinit var settingsPrefs: SettingsPrefs
|
|
||||||
@Inject lateinit var worldClockRepository: WorldClockRepository
|
@Inject lateinit var worldClockRepository: WorldClockRepository
|
||||||
@Inject lateinit var zoneNames: ZoneNames
|
@Inject lateinit var zoneNames: ZoneNames
|
||||||
|
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
@Composable
|
||||||
super.onCreate(savedInstanceState)
|
override fun Content() {
|
||||||
val appWidgetId = intent?.getIntExtra(AppWidgetManager.EXTRA_APPWIDGET_ID, AppWidgetManager.INVALID_APPWIDGET_ID)
|
// `null` until this instance's stored settings are read: the screen
|
||||||
?: AppWidgetManager.INVALID_APPWIDGET_ID
|
// (and its OK) only appears then, so neither an early save nor a
|
||||||
// Backing out cancels the placement, as the framework expects.
|
// late load can write defaults over what the user has.
|
||||||
setResult(RESULT_CANCELED, resultIntent(appWidgetId))
|
var settings by remember { mutableStateOf<DigitalClockSettings?>(null) }
|
||||||
if (appWidgetId == AppWidgetManager.INVALID_APPWIDGET_ID) {
|
val cities by produceState(initialValue = emptyList<ClockWidgetCity>()) {
|
||||||
finish()
|
value = widgetCities(worldClockRepository, zoneNames)
|
||||||
return
|
|
||||||
}
|
}
|
||||||
// A stale id (the widget removed meanwhile) or one that isn't ours
|
LaunchedEffect(glanceId) {
|
||||||
// throws here; there is nothing to configure, so just close.
|
val state = getAppWidgetState(this@ClockWidgetConfigActivity, PreferencesGlanceStateDefinition, glanceId)
|
||||||
val glanceId = runCatching { GlanceAppWidgetManager(this).getGlanceIdBy(appWidgetId) }.getOrNull()
|
settings = parseDigitalClockSettings(
|
||||||
if (glanceId == null) {
|
style = state[WIDGET_DIGITAL_STYLE_KEY],
|
||||||
finish()
|
showDate = state[WIDGET_SHOW_DATE_KEY],
|
||||||
return
|
showWorldClocks = state[WIDGET_SHOW_WORLD_CLOCKS_KEY],
|
||||||
}
|
)
|
||||||
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() },
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
val current = settings ?: return
|
||||||
|
ClockWidgetConfigScreen(
|
||||||
|
settings = current,
|
||||||
|
cities = cities,
|
||||||
|
onSettingsChange = { settings = it },
|
||||||
|
onSave = {
|
||||||
|
save {
|
||||||
|
updateAppWidgetState(this@ClockWidgetConfigActivity, glanceId) { prefs ->
|
||||||
|
prefs[WIDGET_DIGITAL_STYLE_KEY] = current.style.name
|
||||||
|
prefs[WIDGET_SHOW_DATE_KEY] = current.showDate
|
||||||
|
prefs[WIDGET_SHOW_WORLD_CLOCKS_KEY] = current.showWorldClocks
|
||||||
|
}
|
||||||
|
ClockWidget().update(this@ClockWidgetConfigActivity, glanceId)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onDismiss = ::dismiss,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun resultIntent(appWidgetId: Int) =
|
|
||||||
Intent().putExtra(AppWidgetManager.EXTRA_APPWIDGET_ID, appWidgetId)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
|
|||||||
+28
-78
@@ -1,105 +1,55 @@
|
|||||||
package de.jeanlucmakiola.clockula.ui.widget.config
|
package de.jeanlucmakiola.clockula.ui.widget.config
|
||||||
|
|
||||||
import android.appwidget.AppWidgetManager
|
import androidx.compose.runtime.Composable
|
||||||
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.LaunchedEffect
|
||||||
import androidx.compose.runtime.collectAsState
|
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Modifier
|
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.glance.appwidget.GlanceAppWidgetManager
|
|
||||||
import androidx.glance.appwidget.state.getAppWidgetState
|
import androidx.glance.appwidget.state.getAppWidgetState
|
||||||
import androidx.glance.appwidget.state.updateAppWidgetState
|
import androidx.glance.appwidget.state.updateAppWidgetState
|
||||||
import androidx.glance.state.PreferencesGlanceStateDefinition
|
import androidx.glance.state.PreferencesGlanceStateDefinition
|
||||||
import androidx.lifecycle.lifecycleScope
|
|
||||||
import dagger.hilt.android.AndroidEntryPoint
|
import dagger.hilt.android.AndroidEntryPoint
|
||||||
import de.jeanlucmakiola.clockula.R
|
import de.jeanlucmakiola.clockula.R
|
||||||
import de.jeanlucmakiola.clockula.data.prefs.SettingsPrefs
|
|
||||||
import de.jeanlucmakiola.clockula.domain.format.ClockFormat
|
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.QuickTimerWidgetSettings
|
||||||
import de.jeanlucmakiola.clockula.domain.widget.parseQuickTimerDuration
|
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.QuickTimerWidget
|
||||||
import de.jeanlucmakiola.clockula.widget.WIDGET_QUICK_TIMER_DURATION_KEY
|
import de.jeanlucmakiola.clockula.widget.WIDGET_QUICK_TIMER_DURATION_KEY
|
||||||
import de.jeanlucmakiola.floret.components.OptionPicker
|
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
|
* The quick-timer widget's entire configuration: which duration it starts on
|
||||||
* starts on tap — reuses `domain/timer/TimerPresets.BUILT_IN`, the same list
|
* tap — [QuickTimerWidgetSettings.OPTIONS], the Timers screen's own built-in
|
||||||
* the Timers screen's own quick-start row offers, so this picker never
|
* quick-start durations up to the widget's 59:59 cap. Like
|
||||||
* invents a second set of durations. Like [AnalogClockConfigActivity], tapping
|
* [AnalogClockConfigActivity], tapping an option saves and finishes
|
||||||
* an option saves and finishes immediately.
|
* immediately.
|
||||||
*/
|
*/
|
||||||
@AndroidEntryPoint
|
@AndroidEntryPoint
|
||||||
class QuickTimerConfigActivity : ComponentActivity() {
|
class QuickTimerConfigActivity : WidgetConfigActivity() {
|
||||||
|
|
||||||
@Inject lateinit var settingsPrefs: SettingsPrefs
|
@Composable
|
||||||
|
override fun Content() {
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
var duration by remember { mutableStateOf(QuickTimerWidgetSettings.DEFAULT) }
|
||||||
super.onCreate(savedInstanceState)
|
LaunchedEffect(glanceId) {
|
||||||
val appWidgetId = intent?.getIntExtra(AppWidgetManager.EXTRA_APPWIDGET_ID, AppWidgetManager.INVALID_APPWIDGET_ID)
|
val state = getAppWidgetState(this@QuickTimerConfigActivity, PreferencesGlanceStateDefinition, glanceId)
|
||||||
?: AppWidgetManager.INVALID_APPWIDGET_ID
|
duration = parseQuickTimerDuration(state[WIDGET_QUICK_TIMER_DURATION_KEY])
|
||||||
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() },
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
OptionPicker(
|
||||||
|
title = stringResource(R.string.widget_quick_timer_config_title),
|
||||||
|
options = QuickTimerWidgetSettings.OPTIONS,
|
||||||
|
selected = duration,
|
||||||
|
label = { ClockFormat.countdown(it) },
|
||||||
|
onSelect = { picked ->
|
||||||
|
save {
|
||||||
|
updateAppWidgetState(this@QuickTimerConfigActivity, glanceId) { prefs ->
|
||||||
|
prefs[WIDGET_QUICK_TIMER_DURATION_KEY] = picked.inWholeMilliseconds
|
||||||
|
}
|
||||||
|
QuickTimerWidget().update(this@QuickTimerConfigActivity, glanceId)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onDismiss = ::dismiss,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun resultIntent(appWidgetId: Int) =
|
|
||||||
Intent().putExtra(AppWidgetManager.EXTRA_APPWIDGET_ID, appWidgetId)
|
|
||||||
}
|
}
|
||||||
|
|||||||
+104
@@ -0,0 +1,104 @@
|
|||||||
|
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.Composable
|
||||||
|
import androidx.compose.runtime.collectAsState
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.glance.GlanceId
|
||||||
|
import androidx.glance.appwidget.GlanceAppWidgetManager
|
||||||
|
import androidx.lifecycle.lifecycleScope
|
||||||
|
import de.jeanlucmakiola.clockula.data.prefs.SettingsPrefs
|
||||||
|
import de.jeanlucmakiola.clockula.ui.theme.ClockulaTheme
|
||||||
|
import de.jeanlucmakiola.floret.prefs.Appearance
|
||||||
|
import de.jeanlucmakiola.floret.prefs.isDark
|
||||||
|
import kotlinx.coroutines.NonCancellable
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What every widget config screen shares: resolving the instance it was
|
||||||
|
* launched for, the configure-result contract, and the app's theme.
|
||||||
|
*
|
||||||
|
* Runs when a widget is placed (below API 31, or whenever the launcher doesn't
|
||||||
|
* honour `configuration_optional`) and again from "Edit widget". The result
|
||||||
|
* starts as `RESULT_CANCELED`, so backing out cancels a placement, as the
|
||||||
|
* framework expects; only [save] turns it into `RESULT_OK`.
|
||||||
|
*/
|
||||||
|
abstract class WidgetConfigActivity : ComponentActivity() {
|
||||||
|
|
||||||
|
@Inject lateinit var settingsPrefs: SettingsPrefs
|
||||||
|
|
||||||
|
protected lateinit var glanceId: GlanceId
|
||||||
|
private set
|
||||||
|
|
||||||
|
private var appWidgetId = AppWidgetManager.INVALID_APPWIDGET_ID
|
||||||
|
|
||||||
|
/** Set once [save] starts, so the picker's own dismiss can't finish (and cancel) underneath it. */
|
||||||
|
private var saving = false
|
||||||
|
|
||||||
|
/** The screen itself, inside [ConfigTheme]; [glanceId] is resolved by the time it runs. */
|
||||||
|
@Composable
|
||||||
|
protected abstract fun Content()
|
||||||
|
|
||||||
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
|
super.onCreate(savedInstanceState)
|
||||||
|
appWidgetId = intent?.getIntExtra(AppWidgetManager.EXTRA_APPWIDGET_ID, AppWidgetManager.INVALID_APPWIDGET_ID)
|
||||||
|
?: AppWidgetManager.INVALID_APPWIDGET_ID
|
||||||
|
setResult(RESULT_CANCELED, resultIntent())
|
||||||
|
// A missing or stale id (the widget removed meanwhile), or one that
|
||||||
|
// isn't ours, throws here; there is nothing to configure, so just close.
|
||||||
|
val id = appWidgetId.takeIf { it != AppWidgetManager.INVALID_APPWIDGET_ID }
|
||||||
|
?.let { runCatching { GlanceAppWidgetManager(this).getGlanceIdBy(it) }.getOrNull() }
|
||||||
|
if (id == null) {
|
||||||
|
finish()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
glanceId = id
|
||||||
|
enableEdgeToEdge()
|
||||||
|
setContent { ConfigTheme { Content() } }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Writes the instance's state via [write], then confirms and closes.
|
||||||
|
* A picker that dismisses itself right after selecting is fine: [dismiss]
|
||||||
|
* is a no-op from here on, and the write runs to completion even if the
|
||||||
|
* activity is torn down meanwhile.
|
||||||
|
*/
|
||||||
|
protected fun save(write: suspend () -> Unit) {
|
||||||
|
if (saving) return
|
||||||
|
saving = true
|
||||||
|
lifecycleScope.launch {
|
||||||
|
withContext(NonCancellable) { write() }
|
||||||
|
setResult(RESULT_OK, resultIntent())
|
||||||
|
finish()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Back out without saving — unless a [save] is already under way. */
|
||||||
|
protected fun dismiss() {
|
||||||
|
if (!saving) finish()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun ConfigTheme(content: @Composable () -> Unit) {
|
||||||
|
val appearance by settingsPrefs.appearance.collectAsState(initial = Appearance())
|
||||||
|
val darkTheme = appearance.themeMode.isDark(isSystemInDarkTheme())
|
||||||
|
ClockulaTheme(darkTheme = darkTheme, dynamicColor = appearance.dynamicColor) {
|
||||||
|
Surface(modifier = Modifier.fillMaxSize(), color = MaterialTheme.colorScheme.surface) {}
|
||||||
|
content()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun resultIntent() = Intent().putExtra(AppWidgetManager.EXTRA_APPWIDGET_ID, appWidgetId)
|
||||||
|
}
|
||||||
@@ -1,6 +1,8 @@
|
|||||||
package de.jeanlucmakiola.clockula.widget
|
package de.jeanlucmakiola.clockula.widget
|
||||||
|
|
||||||
|
import android.appwidget.AppWidgetManager
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
import androidx.glance.GlanceId
|
import androidx.glance.GlanceId
|
||||||
import androidx.glance.appwidget.GlanceAppWidget
|
import androidx.glance.appwidget.GlanceAppWidget
|
||||||
import androidx.glance.appwidget.GlanceAppWidgetReceiver
|
import androidx.glance.appwidget.GlanceAppWidgetReceiver
|
||||||
@@ -34,4 +36,24 @@ class AnalogClockWidget : GlanceAppWidget() {
|
|||||||
|
|
||||||
class AnalogClockWidgetReceiver : GlanceAppWidgetReceiver() {
|
class AnalogClockWidgetReceiver : GlanceAppWidgetReceiver() {
|
||||||
override val glanceAppWidget: GlanceAppWidget = AnalogClockWidget()
|
override val glanceAppWidget: GlanceAppWidget = AnalogClockWidget()
|
||||||
|
|
||||||
|
override fun onReceive(context: Context, intent: Intent) {
|
||||||
|
// Midnight: redraw every analog widget, as the platform's own update
|
||||||
|
// would, which re-arms the next one in `onUpdate`.
|
||||||
|
if (intent.action == AnalogDateRefresh.ACTION_REFRESH) {
|
||||||
|
onUpdate(context, AppWidgetManager.getInstance(context), AnalogDateRefresh.placedIds(context))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
super.onReceive(context, intent)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onUpdate(context: Context, appWidgetManager: AppWidgetManager, appWidgetIds: IntArray) {
|
||||||
|
super.onUpdate(context, appWidgetManager, appWidgetIds)
|
||||||
|
AnalogDateRefresh.schedule(context)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onDisabled(context: Context) {
|
||||||
|
super.onDisabled(context)
|
||||||
|
AnalogDateRefresh.cancel(context)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,59 @@
|
|||||||
|
package de.jeanlucmakiola.clockula.widget
|
||||||
|
|
||||||
|
import android.app.AlarmManager
|
||||||
|
import android.app.PendingIntent
|
||||||
|
import android.appwidget.AppWidgetManager
|
||||||
|
import android.content.ComponentName
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import de.jeanlucmakiola.clockula.widget.di.widgetEntryPoint
|
||||||
|
import java.time.Instant
|
||||||
|
import java.time.ZonedDateTime
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Keeps the analog faces' date current. The dated faces draw today's date
|
||||||
|
* into a bitmap hand (`ui/widget/AnalogClockWidgetContent.kt`), which only
|
||||||
|
* changes when the widget is re-rendered — and nothing the app writes
|
||||||
|
* re-renders it. So the analog widget is redrawn at each local midnight (a
|
||||||
|
* non-waking, windowed alarm: a dark screen has no date to show, and a
|
||||||
|
* minute's slack lets the platform batch it) and on a clock or zone change
|
||||||
|
* (`SystemEventReceiver`).
|
||||||
|
*/
|
||||||
|
object AnalogDateRefresh {
|
||||||
|
|
||||||
|
const val ACTION_REFRESH = "de.jeanlucmakiola.clockula.widget.action.REFRESH_ANALOG_DATE"
|
||||||
|
|
||||||
|
/** Arms the next midnight redraw while any analog widget is placed, and disarms it otherwise. */
|
||||||
|
fun schedule(context: Context) {
|
||||||
|
val manager = context.getSystemService(AlarmManager::class.java) ?: return
|
||||||
|
val pendingIntent = pendingIntent(context)
|
||||||
|
if (placedIds(context).isEmpty()) {
|
||||||
|
manager.cancel(pendingIntent)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
val entryPoint = widgetEntryPoint(context)
|
||||||
|
val zone = entryPoint.zoneProvider().current()
|
||||||
|
val now = ZonedDateTime.ofInstant(Instant.ofEpochMilli(entryPoint.wallClock().now().toEpochMilliseconds()), zone)
|
||||||
|
val midnight = now.toLocalDate().plusDays(1).atStartOfDay(zone).toInstant().toEpochMilli()
|
||||||
|
manager.setWindow(AlarmManager.RTC, midnight, WINDOW_MILLIS, pendingIntent)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun cancel(context: Context) {
|
||||||
|
context.getSystemService(AlarmManager::class.java)?.cancel(pendingIntent(context))
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The placed analog widgets' ids — what an [ACTION_REFRESH] hands to `onUpdate`. */
|
||||||
|
fun placedIds(context: Context): IntArray =
|
||||||
|
AppWidgetManager.getInstance(context)
|
||||||
|
.getAppWidgetIds(ComponentName(context, AnalogClockWidgetReceiver::class.java))
|
||||||
|
|
||||||
|
private fun pendingIntent(context: Context): PendingIntent =
|
||||||
|
PendingIntent.getBroadcast(
|
||||||
|
context,
|
||||||
|
0,
|
||||||
|
Intent(context, AnalogClockWidgetReceiver::class.java).setAction(ACTION_REFRESH),
|
||||||
|
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
|
||||||
|
)
|
||||||
|
|
||||||
|
private const val WINDOW_MILLIS = 60_000L
|
||||||
|
}
|
||||||
@@ -7,6 +7,7 @@ import androidx.glance.appwidget.GlanceAppWidgetReceiver
|
|||||||
import androidx.glance.appwidget.SizeMode
|
import androidx.glance.appwidget.SizeMode
|
||||||
import androidx.glance.appwidget.provideContent
|
import androidx.glance.appwidget.provideContent
|
||||||
import de.jeanlucmakiola.clockula.ui.widget.ClockWidgetRoot
|
import de.jeanlucmakiola.clockula.ui.widget.ClockWidgetRoot
|
||||||
|
import de.jeanlucmakiola.clockula.ui.widget.widgetCities
|
||||||
import de.jeanlucmakiola.clockula.widget.di.widgetEntryPoint
|
import de.jeanlucmakiola.clockula.widget.di.widgetEntryPoint
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -14,7 +15,7 @@ import de.jeanlucmakiola.clockula.widget.di.widgetEntryPoint
|
|||||||
* read reactively inside [ClockWidgetRoot] (`ui/widget/ClockWidgetContent.kt`)
|
* read reactively inside [ClockWidgetRoot] (`ui/widget/ClockWidgetContent.kt`)
|
||||||
* from this instance's own Glance state (`widget/ClockWidgetState.kt`),
|
* from this instance's own Glance state (`widget/ClockWidgetState.kt`),
|
||||||
* written by `ClockWidgetConfigActivity`; an instance placed with
|
* written by `ClockWidgetConfigActivity`; an instance placed with
|
||||||
* configuration skipped has none set, which [parseStoredClockWidgetState]
|
* configuration skipped has none set, which `parseDigitalClockSettings`
|
||||||
* resolves to the defaults that reproduce the pre-M11 layout.
|
* resolves to the defaults that reproduce the pre-M11 layout.
|
||||||
*
|
*
|
||||||
* Reading that state via `currentState()` *inside* the composition — rather
|
* Reading that state via `currentState()` *inside* the composition — rather
|
||||||
@@ -33,8 +34,9 @@ class ClockWidget : GlanceAppWidget() {
|
|||||||
|
|
||||||
override suspend fun provideGlance(context: Context, id: GlanceId) {
|
override suspend fun provideGlance(context: Context, id: GlanceId) {
|
||||||
val entryPoint = widgetEntryPoint(context)
|
val entryPoint = widgetEntryPoint(context)
|
||||||
|
val initialCities = widgetCities(entryPoint.worldClockRepository(), entryPoint.zoneNames())
|
||||||
provideContent {
|
provideContent {
|
||||||
ClockWidgetRoot(entryPoint)
|
ClockWidgetRoot(entryPoint, initialCities)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,10 +6,7 @@ import androidx.datastore.preferences.core.stringPreferencesKey
|
|||||||
import de.jeanlucmakiola.clockula.domain.widget.AnalogClockStyle
|
import de.jeanlucmakiola.clockula.domain.widget.AnalogClockStyle
|
||||||
import de.jeanlucmakiola.clockula.domain.widget.DigitalClockSettings
|
import de.jeanlucmakiola.clockula.domain.widget.DigitalClockSettings
|
||||||
import de.jeanlucmakiola.clockula.domain.widget.DigitalClockStyle
|
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.parseDigitalClockStyle
|
||||||
import de.jeanlucmakiola.clockula.domain.widget.parseQuickTimerDuration
|
|
||||||
|
|
||||||
/** Per-instance Glance state key holding the digital widget's [DigitalClockStyle] ([name]). */
|
/** Per-instance Glance state key holding the digital widget's [DigitalClockStyle] ([name]). */
|
||||||
internal val WIDGET_DIGITAL_STYLE_KEY = stringPreferencesKey("digital_style")
|
internal val WIDGET_DIGITAL_STYLE_KEY = stringPreferencesKey("digital_style")
|
||||||
@@ -35,9 +32,3 @@ internal fun parseDigitalClockSettings(style: String?, showDate: Boolean?, showW
|
|||||||
showDate = showDate ?: true,
|
showDate = showDate ?: true,
|
||||||
showWorldClocks = showWorldClocks ?: false,
|
showWorldClocks = showWorldClocks ?: false,
|
||||||
)
|
)
|
||||||
|
|
||||||
internal fun quickTimerSettings(durationMillis: Long?): QuickTimerWidgetSettings =
|
|
||||||
QuickTimerWidgetSettings(duration = parseQuickTimerDuration(durationMillis))
|
|
||||||
|
|
||||||
internal val QuickTimerWidgetSettings.durationMillis: Long
|
|
||||||
get() = duration.inWholeMilliseconds
|
|
||||||
|
|||||||
@@ -6,10 +6,15 @@ import de.jeanlucmakiola.clockula.alarm.AlarmEngine
|
|||||||
import de.jeanlucmakiola.clockula.data.stopwatch.StopwatchRepository
|
import de.jeanlucmakiola.clockula.data.stopwatch.StopwatchRepository
|
||||||
import de.jeanlucmakiola.clockula.data.timers.TimerRepository
|
import de.jeanlucmakiola.clockula.data.timers.TimerRepository
|
||||||
import de.jeanlucmakiola.clockula.data.worldclocks.WorldClockRepository
|
import de.jeanlucmakiola.clockula.data.worldclocks.WorldClockRepository
|
||||||
|
import androidx.glance.appwidget.GlanceAppWidget
|
||||||
import androidx.glance.appwidget.updateAll
|
import androidx.glance.appwidget.updateAll
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.FlowPreview
|
import kotlinx.coroutines.FlowPreview
|
||||||
|
import kotlinx.coroutines.coroutineScope
|
||||||
|
import kotlinx.coroutines.flow.Flow
|
||||||
import kotlinx.coroutines.flow.combine
|
import kotlinx.coroutines.flow.combine
|
||||||
import kotlinx.coroutines.flow.debounce
|
import kotlinx.coroutines.flow.debounce
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
import javax.inject.Singleton
|
import javax.inject.Singleton
|
||||||
@@ -24,8 +29,11 @@ import kotlin.time.Duration.Companion.milliseconds
|
|||||||
* because the process is alive whenever those run — it touches no engine
|
* because the process is alive whenever those run — it touches no engine
|
||||||
* itself, only re-renders.
|
* itself, only re-renders.
|
||||||
*
|
*
|
||||||
* The quick-timer widget follows the timers too: it shows the countdown of
|
* Each flow re-renders only the widgets that read it: a stopwatch lap redraws
|
||||||
* the timer it started (`widget/QuickTimerWidget.kt`).
|
* the stopwatch, not the clocks. The quick-timer widget follows the timers,
|
||||||
|
* since it shows the countdown of the timer it started
|
||||||
|
* (`widget/QuickTimerWidget.kt`). The analog clock reads none of them; its
|
||||||
|
* date is kept current by [AnalogDateRefresh].
|
||||||
*
|
*
|
||||||
* Started once, from [de.jeanlucmakiola.clockula.ClockulaApp.onCreate], in the
|
* Started once, from [de.jeanlucmakiola.clockula.ClockulaApp.onCreate], in the
|
||||||
* app's `@ApplicationScope` — never driven by a `Ticker`, so an idle home
|
* app's `@ApplicationScope` — never driven by a `Ticker`, so an idle home
|
||||||
@@ -40,37 +48,25 @@ class WidgetSync @Inject constructor(
|
|||||||
private val worldClocks: WorldClockRepository,
|
private val worldClocks: WorldClockRepository,
|
||||||
) {
|
) {
|
||||||
|
|
||||||
|
suspend fun run(): Unit = coroutineScope {
|
||||||
|
sync(alarmEngine.upcoming(), NextAlarmWidget())
|
||||||
|
sync(timers.timers(), TimerWidget(), QuickTimerWidget())
|
||||||
|
// A lap leaves the run untouched, so the lap count is watched too.
|
||||||
|
sync(combine(stopwatch.run(), stopwatch.laps()) { run, laps -> run to laps.size }, StopwatchWidget())
|
||||||
|
sync(worldClocks.worldClocks(), ClockWidget())
|
||||||
|
}
|
||||||
|
|
||||||
@OptIn(FlowPreview::class)
|
@OptIn(FlowPreview::class)
|
||||||
suspend fun run() {
|
private fun CoroutineScope.sync(source: Flow<*>, vararg widgets: GlanceAppWidget) = launch {
|
||||||
combine(
|
source
|
||||||
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, stopwatchRun, clocks ->
|
|
||||||
WidgetSyncState(upcoming, timerRows, stopwatchRun, clocks)
|
|
||||||
}
|
|
||||||
.distinctUntilChanged()
|
.distinctUntilChanged()
|
||||||
// A burst of writes (an import's replace-all, a reorder) collapses
|
// A burst of writes (an import's replace-all, a reorder) collapses
|
||||||
// to one redraw rather than one per row.
|
// to one redraw rather than one per row.
|
||||||
.debounce(DEBOUNCE)
|
.debounce(DEBOUNCE)
|
||||||
.collect { updateAll() }
|
.collect { widgets.forEach { it.updateAll(context) } }
|
||||||
}
|
|
||||||
|
|
||||||
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 {
|
private companion object {
|
||||||
val DEBOUNCE = 150.milliseconds
|
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,7 +6,7 @@
|
|||||||
android:minResizeHeight="110dp"
|
android:minResizeHeight="110dp"
|
||||||
android:targetCellWidth="2"
|
android:targetCellWidth="2"
|
||||||
android:targetCellHeight="2"
|
android:targetCellHeight="2"
|
||||||
android:updatePeriodMillis="1800000"
|
android:updatePeriodMillis="0"
|
||||||
android:resizeMode="horizontal|vertical"
|
android:resizeMode="horizontal|vertical"
|
||||||
android:widgetCategory="home_screen"
|
android:widgetCategory="home_screen"
|
||||||
android:description="@string/widget_analog_description"
|
android:description="@string/widget_analog_description"
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
<appwidget-provider xmlns:android="http://schemas.android.com/apk/res/android"
|
<appwidget-provider xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
android:minWidth="140dp"
|
android:minWidth="140dp"
|
||||||
android:minHeight="80dp"
|
android:minHeight="110dp"
|
||||||
android:minResizeWidth="110dp"
|
android:minResizeWidth="110dp"
|
||||||
android:minResizeHeight="60dp"
|
android:minResizeHeight="110dp"
|
||||||
android:targetCellWidth="3"
|
android:targetCellWidth="3"
|
||||||
android:targetCellHeight="2"
|
android:targetCellHeight="2"
|
||||||
android:updatePeriodMillis="0"
|
android:updatePeriodMillis="0"
|
||||||
|
|||||||
@@ -58,6 +58,17 @@ class ClockWidgetSettingsTest {
|
|||||||
assertThat(parseQuickTimerDuration(0L)).isEqualTo(QuickTimerWidgetSettings.DEFAULT)
|
assertThat(parseQuickTimerDuration(0L)).isEqualTo(QuickTimerWidgetSettings.DEFAULT)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a quick-timer duration past 59 59 parses to the default`() {
|
||||||
|
assertThat(parseQuickTimerDuration(3_600_000L)).isEqualTo(QuickTimerWidgetSettings.DEFAULT)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `the quick-timer options stop at 59 59`() {
|
||||||
|
assertThat(QuickTimerWidgetSettings.OPTIONS).isNotEmpty()
|
||||||
|
assertThat(QuickTimerWidgetSettings.OPTIONS.all { it <= QuickTimerWidgetSettings.MAX }).isTrue()
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `an in-range quick-timer duration round-trips`() {
|
fun `an in-range quick-timer duration round-trips`() {
|
||||||
assertThat(parseQuickTimerDuration(600_000L).inWholeMilliseconds).isEqualTo(600_000L)
|
assertThat(parseQuickTimerDuration(600_000L).inWholeMilliseconds).isEqualTo(600_000L)
|
||||||
|
|||||||
Reference in New Issue
Block a user