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,
|
||||
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). -->
|
||||
is the one refresh trigger for the data widgets, and the analog
|
||||
widget's date is redrawn at midnight and on a clock or zone
|
||||
change (widget/AnalogDateRefresh.kt). -->
|
||||
<receiver
|
||||
android:name=".widget.NextAlarmWidgetReceiver"
|
||||
android:exported="true">
|
||||
|
||||
@@ -2,10 +2,13 @@ package de.jeanlucmakiola.clockula.alarm.receiver
|
||||
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import androidx.glance.appwidget.updateAll
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
import de.jeanlucmakiola.clockula.alarm.AlarmEngine
|
||||
import de.jeanlucmakiola.clockula.stopwatch.StopwatchEngine
|
||||
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 kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.launch
|
||||
@@ -79,6 +82,10 @@ class SystemEventReceiver : HiltBroadcastReceiver() {
|
||||
// the notification re-posts with a freshly derived
|
||||
// chronometer base (M7 D4, D17).
|
||||
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 {
|
||||
|
||||
+15
-7
@@ -5,19 +5,27 @@ 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.Duration.Companion.seconds
|
||||
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
|
||||
}
|
||||
/** The quick-timer widget's one setting: the duration it starts on tap. */
|
||||
object QuickTimerWidgetSettings {
|
||||
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 {
|
||||
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.content.Context
|
||||
import android.content.Intent
|
||||
import androidx.glance.GlanceId
|
||||
import androidx.glance.appwidget.GlanceAppWidgetManager
|
||||
import androidx.glance.appwidget.state.getAppWidgetState
|
||||
import androidx.glance.appwidget.state.updateAppWidgetState
|
||||
import androidx.glance.state.PreferencesGlanceStateDefinition
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
import de.jeanlucmakiola.clockula.alarm.receiver.HiltBroadcastReceiver
|
||||
import de.jeanlucmakiola.clockula.data.timers.TimerRepository
|
||||
import de.jeanlucmakiola.clockula.domain.TimerDraft
|
||||
import de.jeanlucmakiola.clockula.domain.TimerState
|
||||
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.flow.first
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import javax.inject.Inject
|
||||
import kotlin.time.Duration.Companion.milliseconds
|
||||
|
||||
@@ -47,20 +54,39 @@ class QuickTimerReceiver : HiltBroadcastReceiver() {
|
||||
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)
|
||||
// Serialised, so a double tap's second start sees the first
|
||||
// one's timer instead of racing it.
|
||||
startMutex.withLock {
|
||||
val glanceId = runCatching { GlanceAppWidgetManager(context).getGlanceIdBy(appWidgetId) }.getOrNull()
|
||||
// A second tap before the widget re-rendered into its
|
||||
// running state: this widget already has a live timer,
|
||||
// so leave it be rather than start one it can't control.
|
||||
if (glanceId != null && hasActiveTimer(context, glanceId)) return@withLock
|
||||
// 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`).
|
||||
if (glanceId != null) {
|
||||
updateAppWidgetState(context, glanceId) { prefs -> prefs[WIDGET_QUICK_TIMER_ID_KEY] = id }
|
||||
QuickTimerWidget().update(context, glanceId)
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
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
|
||||
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.Canvas
|
||||
import android.graphics.Paint
|
||||
@@ -26,11 +25,9 @@ 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]. */
|
||||
@@ -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
|
||||
* 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.
|
||||
* It is redrawn at midnight and on a clock or zone change
|
||||
* (`widget/AnalogDateRefresh.kt`).
|
||||
*/
|
||||
private fun dateHand(context: Context): Bitmap {
|
||||
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)
|
||||
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
|
||||
|
||||
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.produceState
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.datastore.preferences.core.Preferences
|
||||
import androidx.glance.GlanceModifier
|
||||
import androidx.glance.LocalContext
|
||||
@@ -15,51 +14,56 @@ import androidx.glance.appwidget.action.actionStartActivity
|
||||
import androidx.glance.currentState
|
||||
import androidx.glance.layout.Box
|
||||
import androidx.glance.layout.fillMaxSize
|
||||
import de.jeanlucmakiola.clockula.MainActivity
|
||||
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.Flow
|
||||
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. */
|
||||
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 world clocks the digital widget shows, resolved to city names, as they change. */
|
||||
internal fun widgetCitiesFlow(worldClocks: WorldClockRepository, zoneNames: ZoneNames): Flow<List<ClockWidgetCity>> =
|
||||
worldClocks.worldClocks().map { clocks ->
|
||||
ClockWidgetPresenter.present(clocks).map { clock ->
|
||||
ClockWidgetCity(
|
||||
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
|
||||
* 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].
|
||||
* Reads this widget instance's own state and the world clocks — both
|
||||
* reactively, so a config-screen save or a world-clock edit 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]. [initialCities] only makes the first frame right.
|
||||
*/
|
||||
@Composable
|
||||
fun ClockWidgetRoot(entryPoint: WidgetEntryPoint) {
|
||||
fun ClockWidgetRoot(entryPoint: WidgetEntryPoint, initialCities: List<ClockWidgetCity>) {
|
||||
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()
|
||||
}
|
||||
val cities by remember { widgetCitiesFlow(entryPoint.worldClockRepository(), entryPoint.zoneNames()) }
|
||||
.collectAsState(initial = initialCities)
|
||||
|
||||
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
|
||||
* 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
|
||||
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.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
|
||||
@@ -36,7 +35,6 @@ 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].
|
||||
@@ -52,9 +50,9 @@ fun StopwatchWidgetRoot(entryPoint: WidgetEntryPoint, initialRun: StopwatchRun,
|
||||
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)
|
||||
val elapsedRealtime = remember(run, lapCount) { entryPoint.elapsedRealtimeClock().elapsedRealtime() }
|
||||
val model = remember(run, lapCount) { StopwatchWidgetPresenter.present(run, elapsedRealtime, lapCount) }
|
||||
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
|
||||
|
||||
@Composable
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package de.jeanlucmakiola.clockula.ui.widget
|
||||
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.os.Build
|
||||
import android.util.TypedValue
|
||||
import android.widget.RemoteViews
|
||||
@@ -30,9 +31,11 @@ import androidx.glance.layout.fillMaxSize
|
||||
import androidx.glance.layout.size
|
||||
import androidx.glance.layout.wrapContentSize
|
||||
import androidx.glance.unit.ColorProvider
|
||||
import de.jeanlucmakiola.clockula.MainActivity
|
||||
import de.jeanlucmakiola.clockula.R
|
||||
import de.jeanlucmakiola.clockula.ui.theme.ClockulaDarkFallback
|
||||
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.WidgetThemeMode
|
||||
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 =
|
||||
ConfigurationCompat.getLocales(context.resources.configuration).get(0) ?: Locale.getDefault()
|
||||
|
||||
+24
-73
@@ -1,20 +1,9 @@
|
||||
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
|
||||
@@ -24,24 +13,16 @@ 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
|
||||
@@ -49,63 +30,33 @@ import javax.inject.Inject
|
||||
* straight away, as the reference picker does (no separate "Save" step).
|
||||
*/
|
||||
@AndroidEntryPoint
|
||||
class AnalogClockConfigActivity : ComponentActivity() {
|
||||
class AnalogClockConfigActivity : WidgetConfigActivity() {
|
||||
|
||||
@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() },
|
||||
)
|
||||
}
|
||||
@Composable
|
||||
override fun Content() {
|
||||
var style by remember { mutableStateOf(AnalogClockStyle.ROUND) }
|
||||
LaunchedEffect(glanceId) {
|
||||
val state = getAppWidgetState(this@AnalogClockConfigActivity, PreferencesGlanceStateDefinition, glanceId)
|
||||
style = parseAnalogClockStyle(state[WIDGET_ANALOG_STYLE_KEY])
|
||||
}
|
||||
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) {
|
||||
|
||||
+34
-82
@@ -1,28 +1,17 @@
|
||||
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
|
||||
@@ -34,20 +23,16 @@ 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
|
||||
@@ -59,83 +44,50 @@ 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".
|
||||
*/
|
||||
/** Picks the digital widget instance's style and the two switches. */
|
||||
@AndroidEntryPoint
|
||||
class ClockWidgetConfigActivity : ComponentActivity() {
|
||||
class ClockWidgetConfigActivity : WidgetConfigActivity() {
|
||||
|
||||
@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
|
||||
@Composable
|
||||
override fun Content() {
|
||||
// `null` until this instance's stored settings are read: the screen
|
||||
// (and its OK) only appears then, so neither an early save nor a
|
||||
// late load can write defaults over what the user has.
|
||||
var settings by remember { mutableStateOf<DigitalClockSettings?>(null) }
|
||||
val cities by produceState(initialValue = emptyList<ClockWidgetCity>()) {
|
||||
value = widgetCities(worldClockRepository, zoneNames)
|
||||
}
|
||||
// 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() },
|
||||
)
|
||||
}
|
||||
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 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
|
||||
|
||||
+28
-78
@@ -1,105 +1,55 @@
|
||||
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.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.
|
||||
* The quick-timer widget's entire configuration: which duration it starts on
|
||||
* tap — [QuickTimerWidgetSettings.OPTIONS], the Timers screen's own built-in
|
||||
* quick-start durations up to the widget's 59:59 cap. Like
|
||||
* [AnalogClockConfigActivity], tapping an option saves and finishes
|
||||
* immediately.
|
||||
*/
|
||||
@AndroidEntryPoint
|
||||
class QuickTimerConfigActivity : ComponentActivity() {
|
||||
class QuickTimerConfigActivity : WidgetConfigActivity() {
|
||||
|
||||
@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() },
|
||||
)
|
||||
}
|
||||
@Composable
|
||||
override fun Content() {
|
||||
var duration by remember { mutableStateOf(QuickTimerWidgetSettings.DEFAULT) }
|
||||
LaunchedEffect(glanceId) {
|
||||
val state = getAppWidgetState(this@QuickTimerConfigActivity, PreferencesGlanceStateDefinition, glanceId)
|
||||
duration = parseQuickTimerDuration(state[WIDGET_QUICK_TIMER_DURATION_KEY])
|
||||
}
|
||||
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
|
||||
|
||||
import android.appwidget.AppWidgetManager
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import androidx.glance.GlanceId
|
||||
import androidx.glance.appwidget.GlanceAppWidget
|
||||
import androidx.glance.appwidget.GlanceAppWidgetReceiver
|
||||
@@ -34,4 +36,24 @@ class AnalogClockWidget : GlanceAppWidget() {
|
||||
|
||||
class AnalogClockWidgetReceiver : GlanceAppWidgetReceiver() {
|
||||
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.provideContent
|
||||
import de.jeanlucmakiola.clockula.ui.widget.ClockWidgetRoot
|
||||
import de.jeanlucmakiola.clockula.ui.widget.widgetCities
|
||||
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`)
|
||||
* from this instance's own Glance state (`widget/ClockWidgetState.kt`),
|
||||
* 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.
|
||||
*
|
||||
* Reading that state via `currentState()` *inside* the composition — rather
|
||||
@@ -33,8 +34,9 @@ class ClockWidget : GlanceAppWidget() {
|
||||
|
||||
override suspend fun provideGlance(context: Context, id: GlanceId) {
|
||||
val entryPoint = widgetEntryPoint(context)
|
||||
val initialCities = widgetCities(entryPoint.worldClockRepository(), entryPoint.zoneNames())
|
||||
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.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")
|
||||
@@ -35,9 +32,3 @@ internal fun parseDigitalClockSettings(style: String?, showDate: Boolean?, showW
|
||||
showDate = showDate ?: true,
|
||||
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.timers.TimerRepository
|
||||
import de.jeanlucmakiola.clockula.data.worldclocks.WorldClockRepository
|
||||
import androidx.glance.appwidget.GlanceAppWidget
|
||||
import androidx.glance.appwidget.updateAll
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.FlowPreview
|
||||
import kotlinx.coroutines.coroutineScope
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.debounce
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import javax.inject.Inject
|
||||
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
|
||||
* itself, only re-renders.
|
||||
*
|
||||
* The quick-timer widget follows the timers too: it shows the countdown of
|
||||
* the timer it started (`widget/QuickTimerWidget.kt`).
|
||||
* Each flow re-renders only the widgets that read it: a stopwatch lap redraws
|
||||
* 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
|
||||
* app's `@ApplicationScope` — never driven by a `Ticker`, so an idle home
|
||||
@@ -40,37 +48,25 @@ class WidgetSync @Inject constructor(
|
||||
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)
|
||||
suspend fun run() {
|
||||
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, stopwatchRun, clocks ->
|
||||
WidgetSyncState(upcoming, timerRows, stopwatchRun, clocks)
|
||||
}
|
||||
private fun CoroutineScope.sync(source: Flow<*>, vararg widgets: GlanceAppWidget) = launch {
|
||||
source
|
||||
.distinctUntilChanged()
|
||||
// A burst of writes (an import's replace-all, a reorder) collapses
|
||||
// to one redraw rather than one per row.
|
||||
.debounce(DEBOUNCE)
|
||||
.collect { updateAll() }
|
||||
}
|
||||
|
||||
private suspend fun updateAll() {
|
||||
NextAlarmWidget().updateAll(context)
|
||||
TimerWidget().updateAll(context)
|
||||
StopwatchWidget().updateAll(context)
|
||||
ClockWidget().updateAll(context)
|
||||
AnalogClockWidget().updateAll(context)
|
||||
QuickTimerWidget().updateAll(context)
|
||||
.collect { widgets.forEach { it.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,7 +6,7 @@
|
||||
android:minResizeHeight="110dp"
|
||||
android:targetCellWidth="2"
|
||||
android:targetCellHeight="2"
|
||||
android:updatePeriodMillis="1800000"
|
||||
android:updatePeriodMillis="0"
|
||||
android:resizeMode="horizontal|vertical"
|
||||
android:widgetCategory="home_screen"
|
||||
android:description="@string/widget_analog_description"
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
<?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:minHeight="110dp"
|
||||
android:minResizeWidth="110dp"
|
||||
android:minResizeHeight="60dp"
|
||||
android:minResizeHeight="110dp"
|
||||
android:targetCellWidth="3"
|
||||
android:targetCellHeight="2"
|
||||
android:updatePeriodMillis="0"
|
||||
|
||||
@@ -58,6 +58,17 @@ class ClockWidgetSettingsTest {
|
||||
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
|
||||
fun `an in-range quick-timer duration round-trips`() {
|
||||
assertThat(parseQuickTimerDuration(600_000L).inWholeMilliseconds).isEqualTo(600_000L)
|
||||
|
||||
Reference in New Issue
Block a user