diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index 7dc63c5..44143a5 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -318,6 +318,16 @@
android:exported="false" />
+
+
+
+
+
= prefs.flow(SettingsKeys.dragToReschedule)
val tapOpensEditor: Flow = prefs.flow(SettingsKeys.tapOpensEditor)
val agendaScreenRange: Flow = prefs.flow(SettingsKeys.agendaScreenRange)
- val agendaWidgetRange: Flow = prefs.flow(SettingsKeys.agendaWidgetRange)
- val widgetSize: Flow = prefs.flow(SettingsKeys.widgetSize)
val agendaShowRangeBar: Flow = prefs.flow(SettingsKeys.agendaShowRangeBar)
val agendaShowToday: Flow = prefs.flow(SettingsKeys.agendaShowToday)
val defaultView: Flow = prefs.flow(SettingsKeys.defaultView)
@@ -231,8 +229,6 @@ class SettingsPrefs @Inject constructor(
suspend fun setDragToReschedule(enabled: Boolean) = prefs.set(SettingsKeys.dragToReschedule, enabled)
suspend fun setTapOpensEditor(enabled: Boolean) = prefs.set(SettingsKeys.tapOpensEditor, enabled)
suspend fun setAgendaScreenRange(range: AgendaRange) = prefs.set(SettingsKeys.agendaScreenRange, range)
- suspend fun setAgendaWidgetRange(range: AgendaRange) = prefs.set(SettingsKeys.agendaWidgetRange, range)
- suspend fun setWidgetSize(size: WidgetSize) = prefs.set(SettingsKeys.widgetSize, size)
suspend fun setAgendaShowRangeBar(enabled: Boolean) = prefs.set(SettingsKeys.agendaShowRangeBar, enabled)
suspend fun setAgendaShowToday(enabled: Boolean) = prefs.set(SettingsKeys.agendaShowToday, enabled)
suspend fun setDefaultView(view: CalendarView) = prefs.set(SettingsKeys.defaultView, view)
@@ -497,13 +493,16 @@ object SettingsKeys {
/** How far ahead the in-app Agenda screen shows events (v2.11). */
val agendaScreenRange: Pref = agendaRangePref("agenda_screen_range")
- /** How far ahead the agenda **widget** shows events (v2.11). */
+ /**
+ * How far ahead the agenda **widget** shows events (v2.11). Read-only since
+ * widgets keep their own options (#292): the default for a widget that has
+ * none yet.
+ */
val agendaWidgetRange: Pref = agendaRangePref("agenda_widget_range")
/**
* The size step the agenda widget draws its text at (#51). [WidgetSize.SMALL]
- * reproduces its original metrics. The month widget takes no size setting —
- * it divides the width it is given by seven (#103).
+ * reproduces its original metrics. Read-only like [agendaWidgetRange] (#292).
*/
val widgetSize: Pref = enumPref("widget_size", WidgetSize.SMALL)
@@ -511,8 +510,8 @@ object SettingsKeys {
val agendaShowRangeBar: Pref = booleanPref("agenda_show_range_bar", true)
/**
- * Whether the agenda (screen and widget) always anchors today at the top with
- * a "nothing left today" placeholder (issue #35).
+ * Whether the agenda screen always anchors today at the top with a "nothing
+ * left today" placeholder (issue #35), and the default for a widget (#292).
*/
val agendaShowToday: Pref = booleanPref("agenda_show_today", true)
diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/SettingsScreen.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/SettingsScreen.kt
index 756dbe0..2ffe433 100644
--- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/SettingsScreen.kt
+++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/SettingsScreen.kt
@@ -155,7 +155,7 @@ fun SettingsScreen(
enter = slideInHorizontally(slideSpec) { it } + fadeIn(),
exit = overlayExit,
) {
- WidgetsScreen(state = state, viewModel = viewModel, onBack = { section = null }, visible = section == SettingsSection.Widgets)
+ WidgetsScreen(onBack = { section = null }, visible = section == SettingsSection.Widgets)
}
AnimatedVisibility(
visible = section == SettingsSection.Licences,
diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/SettingsUiState.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/SettingsUiState.kt
index 5d4dcf1..53c14bb 100644
--- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/SettingsUiState.kt
+++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/SettingsUiState.kt
@@ -16,7 +16,6 @@ import de.jeanlucmakiola.calendula.ui.common.QuickSwitchConfig
import de.jeanlucmakiola.calendula.ui.common.TimelineHours
import de.jeanlucmakiola.calendula.ui.common.TimelineScale
import de.jeanlucmakiola.calendula.ui.month.MonthViewStyle
-import de.jeanlucmakiola.calendula.widget.WidgetSize
/**
* Settings screen state (M4). Persisted preferences are instant to read, so
@@ -51,14 +50,10 @@ data class SettingsUiState(
val tapOpensEditor: Boolean = false,
/** How far ahead the in-app Agenda screen shows events (v2.11). */
val agendaScreenRange: AgendaRange = AgendaRange.Month,
- /** How far ahead the agenda widget shows events (v2.11). */
- val agendaWidgetRange: AgendaRange = AgendaRange.Month,
- /** Whether the agenda (screen + widget) always anchors today at the top (#35). */
+ /** Whether the agenda screen always anchors today at the top (#35); widgets have their own (#292). */
val agendaShowToday: Boolean = true,
/** Whether the agenda shows its top range bar — header + switcher (v2.11). */
val agendaShowRangeBar: Boolean = true,
- /** The size step the agenda widget draws its text at (#51). */
- val widgetSize: WidgetSize = WidgetSize.SMALL,
/** The calendar view the app opens on, and the home of the view back stack (M1). */
val defaultView: CalendarView = CalendarView.Week,
/** Which views the top-bar quick-switch button cycles through, and their order (#24). */
diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/SettingsViewModel.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/SettingsViewModel.kt
index 1d4b93a..8326032 100644
--- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/SettingsViewModel.kt
+++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/SettingsViewModel.kt
@@ -45,7 +45,6 @@ import de.jeanlucmakiola.calendula.ui.common.TimelineScale
import de.jeanlucmakiola.calendula.ui.month.MonthViewStyle
import de.jeanlucmakiola.calendula.ui.theme.AppFontSettings
import de.jeanlucmakiola.calendula.ui.theme.FONT_CUSTOM_TOKEN
-import de.jeanlucmakiola.calendula.widget.WidgetSize
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableSharedFlow
@@ -106,10 +105,8 @@ class SettingsViewModel @Inject constructor(
dragToReschedule = this[SettingsKeys.dragToReschedule],
tapOpensEditor = this[SettingsKeys.tapOpensEditor],
agendaScreenRange = this[SettingsKeys.agendaScreenRange],
- agendaWidgetRange = this[SettingsKeys.agendaWidgetRange],
agendaShowToday = this[SettingsKeys.agendaShowToday],
agendaShowRangeBar = this[SettingsKeys.agendaShowRangeBar],
- widgetSize = this[SettingsKeys.widgetSize],
defaultView = this[SettingsKeys.defaultView],
quickSwitchConfig = this[SettingsKeys.quickSwitchConfig],
monthViewStyle = this[SettingsKeys.monthViewStyle],
@@ -283,13 +280,8 @@ class SettingsViewModel @Inject constructor(
fun setAgendaScreenRange(range: AgendaRange) = save { setAgendaScreenRange(range) }
- fun setAgendaWidgetRange(range: AgendaRange) = save { setAgendaWidgetRange(range) }
-
fun setAgendaShowRangeBar(enabled: Boolean) = save { setAgendaShowRangeBar(enabled) }
- /** The size the agenda widget draws its text at (#51); the month widget sizes itself (#103). */
- fun setWidgetSize(size: WidgetSize) = save { setWidgetSize(size) }
-
fun setAgendaShowToday(enabled: Boolean) = save { setAgendaShowToday(enabled) }
fun setTimeFormat(pref: TimeFormatPref) = save { setTimeFormat(pref) }
diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/WidgetSettings.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/WidgetSettings.kt
index 456efbe..aad1d35 100644
--- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/WidgetSettings.kt
+++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/WidgetSettings.kt
@@ -10,7 +10,9 @@ import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.height
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
-import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.mutableIntStateOf
+import androidx.compose.runtime.produceState
+import androidx.lifecycle.compose.LifecycleResumeEffect
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
@@ -18,56 +20,47 @@ import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import de.jeanlucmakiola.calendula.R
-import de.jeanlucmakiola.calendula.data.prefs.resolveFirstDay
import de.jeanlucmakiola.calendula.qs.NewEventTileService
-import de.jeanlucmakiola.calendula.ui.common.AgendaRangePicker
-import de.jeanlucmakiola.calendula.ui.common.agendaRangeLabel
-import de.jeanlucmakiola.calendula.widget.WidgetSize
-import de.jeanlucmakiola.calendula.widget.agenda.metricsFor
import de.jeanlucmakiola.floret.components.CollapsingScaffold
import de.jeanlucmakiola.floret.components.GroupedHint
import de.jeanlucmakiola.floret.components.GroupedRow
-import de.jeanlucmakiola.floret.components.OptionPicker
import de.jeanlucmakiola.floret.components.Position
-import de.jeanlucmakiola.floret.locale.currentLocale
-import kotlin.math.roundToInt
+import de.jeanlucmakiola.floret.components.GroupedSectionHeader
+import de.jeanlucmakiola.floret.components.positionOf
+import de.jeanlucmakiola.calendula.widget.config.PlacedWidget
+import de.jeanlucmakiola.calendula.widget.config.WidgetConfigActivity
+import de.jeanlucmakiola.calendula.widget.config.WidgetKind
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.withContext
+import de.jeanlucmakiola.calendula.widget.config.backgroundLabel
+import de.jeanlucmakiola.calendula.widget.config.label
+import de.jeanlucmakiola.calendula.widget.config.placedWidgets
+import de.jeanlucmakiola.calendula.widget.config.themeLabel
/**
- * Widgets & tiles (#69): the home-screen widgets' settings and the Quick
- * Settings tile shortcut. The agenda widget keeps its own range and text size,
- * separate from the Agenda screen's range in [ViewsScreen].
+ * Widgets & tiles (#69): the placed home-screen widgets, each opening its own
+ * config screen (#292), and the Quick Settings tile shortcut.
*/
@Composable
internal fun WidgetsScreen(
- state: SettingsUiState,
- viewModel: SettingsViewModel,
onBack: () -> Unit,
visible: Boolean = true,
) {
- var showAgendaWidgetRange by remember { mutableStateOf(false) }
- var showWidgetSize by remember { mutableStateOf(false) }
-
CollapsingScaffold(
title = stringResource(R.string.settings_section_widgets),
onBack = onBack,
predictiveBack = true,
visible = visible,
) {
- GroupedHint(stringResource(R.string.settings_widgets_hint))
- Spacer(Modifier.height(8.dp))
-
- GroupedRow(
- title = stringResource(R.string.settings_agenda_widget_range),
- summary = agendaRangeLabel(state.agendaWidgetRange),
- position = Position.Top,
- onClick = { showAgendaWidgetRange = true },
- )
- GroupedRow(
- title = stringResource(R.string.settings_widget_size),
- summary = widgetSizeLabel(state.widgetSize),
- position = Position.Bottom,
- onClick = { showWidgetSize = true },
+ GroupedHint(
+ stringResource(
+ // The launcher's "Edit widget" action only exists from Android 12.
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) R.string.settings_widgets_hint
+ else R.string.settings_widgets_hint_legacy,
+ ),
)
+ GroupedSectionHeader(stringResource(R.string.settings_widgets_placed))
+ PlacedWidgetRows()
// The add-tile prompt is API 33+; below that the tile is still
// addable from the QS editor, so the row just doesn't appear.
@@ -76,30 +69,44 @@ internal fun WidgetsScreen(
QuickSettingsTileRow()
}
}
+}
- if (showAgendaWidgetRange) {
- AgendaRangePicker(
- title = stringResource(R.string.settings_agenda_widget_range),
- description = stringResource(R.string.settings_agenda_widget_range_hint),
- selected = state.agendaWidgetRange,
- weekStart = state.weekStart.resolveFirstDay(currentLocale()),
- onSelect = viewModel::setAgendaWidgetRange,
- onDismiss = { showAgendaWidgetRange = false },
- )
+@Composable
+private fun PlacedWidgetRows() {
+ val context = LocalContext.current
+ // Read on every resume, the first included, so a widget added, removed or
+ // edited meanwhile shows up.
+ var refresh by remember { mutableIntStateOf(0) }
+ LifecycleResumeEffect(Unit) {
+ refresh++
+ onPauseOrDispose {}
}
- if (showWidgetSize) {
- OptionPicker(
- title = stringResource(R.string.settings_widget_size),
- header = { GroupedHint(stringResource(R.string.settings_widget_size_hint), verticalPadding = 8.dp) },
- predictiveBack = true,
- options = WidgetSize.entries,
- selected = state.widgetSize,
- label = { widgetSizeLabel(it) },
- // No live preview: the widget is Glance/RemoteViews, so a Compose
- // mock-up would be a second implementation free to drift.
- summary = { widgetSizeSummary(it) },
- onSelect = viewModel::setWidgetSize,
- onDismiss = { showWidgetSize = false },
+ val widgets by produceState?>(initialValue = null, refresh) {
+ if (refresh > 0) value = withContext(Dispatchers.IO) { context.placedWidgets() }
+ }
+ val placed = widgets ?: return
+ if (placed.isEmpty()) {
+ GroupedRow(
+ title = stringResource(R.string.settings_widgets_none),
+ position = Position.Alone,
+ dimmed = true,
+ )
+ return
+ }
+ val kindCounts = placed.groupingBy { it.kind }.eachCount()
+ val kindSeen = mutableMapOf()
+ placed.forEachIndexed { index, widget ->
+ val number = kindSeen.merge(widget.kind, 1, Int::plus)!!
+ val label = stringResource(widget.kind.label)
+ GroupedRow(
+ title = if (kindCounts.getValue(widget.kind) > 1) stringResource(R.string.widget_numbered, label, number) else label,
+ summary = stringResource(
+ R.string.widget_appearance_summary,
+ backgroundLabel(widget.appearance.background),
+ themeLabel(widget.appearance.theme),
+ ),
+ position = positionOf(index, placed.size),
+ onClick = { context.startActivity(WidgetConfigActivity.intent(context, widget.appWidgetId)) },
)
}
}
@@ -131,24 +138,3 @@ private fun requestAddQsTile(context: Context) {
context.mainExecutor,
) { /* result code unused — the system surfaces its own feedback */ }
}
-
-/**
- * What a size step does, as a percentage of the smallest one's event text.
- * Derived from the widget's own [metricsFor] table rather than hardcoded.
- */
-@Composable
-private fun widgetSizeSummary(size: WidgetSize): String {
- val base = metricsFor(WidgetSize.SMALL).eventTitle.value
- val percent = (metricsFor(size).eventTitle.value / base * 100f).roundToInt()
- return stringResource(R.string.settings_widget_size_summary, percent)
-}
-
-@Composable
-private fun widgetSizeLabel(size: WidgetSize): String = stringResource(
- when (size) {
- WidgetSize.SMALL -> R.string.settings_widget_size_small
- WidgetSize.MEDIUM -> R.string.settings_widget_size_medium
- WidgetSize.LARGE -> R.string.settings_widget_size_large
- WidgetSize.EXTRA_LARGE -> R.string.settings_widget_size_extra_large
- },
-)
diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/widget/WidgetConfig.kt b/app/src/main/java/de/jeanlucmakiola/calendula/widget/WidgetConfig.kt
new file mode 100644
index 0000000..69ac74d
--- /dev/null
+++ b/app/src/main/java/de/jeanlucmakiola/calendula/widget/WidgetConfig.kt
@@ -0,0 +1,88 @@
+package de.jeanlucmakiola.calendula.widget
+
+import android.content.Context
+import androidx.datastore.preferences.core.MutablePreferences
+import androidx.datastore.preferences.core.Preferences
+import androidx.datastore.preferences.core.booleanPreferencesKey
+import androidx.datastore.preferences.core.stringPreferencesKey
+import androidx.glance.GlanceId
+import androidx.glance.appwidget.state.getAppWidgetState
+import androidx.glance.state.PreferencesGlanceStateDefinition
+import de.jeanlucmakiola.calendula.data.prefs.PastEventDisplay
+import de.jeanlucmakiola.calendula.ui.agenda.AgendaRange
+import de.jeanlucmakiola.calendula.ui.agenda.parseAgendaRange
+import de.jeanlucmakiola.calendula.ui.agenda.storageValue
+import de.jeanlucmakiola.floret.glance.WidgetBackground
+import de.jeanlucmakiola.floret.glance.WidgetThemeMode
+import de.jeanlucmakiola.floret.prefs.ThemeMode
+import de.jeanlucmakiola.floret.prefs.toEnum
+
+/** A widget's own light/dark choice (#292); [FOLLOW_APP] tracks the app's theme setting. */
+enum class WidgetTheme { FOLLOW_APP, LIGHT, DARK }
+
+/** How a widget looks, set per placed widget in its config screen (#292). */
+data class WidgetAppearance(
+ val background: WidgetBackground = WidgetBackground.TINTED,
+ val theme: WidgetTheme = WidgetTheme.FOLLOW_APP,
+)
+
+/** What the agenda widget lists, per placed widget. */
+data class AgendaWidgetOptions(
+ val range: AgendaRange,
+ val textSize: WidgetSize,
+ val showToday: Boolean,
+ val pastDisplay: PastEventDisplay,
+)
+
+internal fun WidgetTheme.resolve(app: ThemeMode): WidgetThemeMode = when (this) {
+ WidgetTheme.LIGHT -> WidgetThemeMode.LIGHT
+ WidgetTheme.DARK -> WidgetThemeMode.DARK
+ WidgetTheme.FOLLOW_APP -> when (app) {
+ ThemeMode.SYSTEM -> WidgetThemeMode.SYSTEM
+ ThemeMode.LIGHT -> WidgetThemeMode.LIGHT
+ ThemeMode.DARK, ThemeMode.BLACK -> WidgetThemeMode.DARK
+ }
+}
+
+private val BackgroundKey = stringPreferencesKey("cfg_background")
+private val ThemeKey = stringPreferencesKey("cfg_theme")
+private val AgendaRangeKey = stringPreferencesKey("cfg_agenda_range")
+private val TextSizeKey = stringPreferencesKey("cfg_text_size")
+private val ShowTodayKey = booleanPreferencesKey("cfg_show_today")
+private val PastDisplayKey = stringPreferencesKey("cfg_past_display")
+
+internal val Preferences.widgetAppearance: WidgetAppearance
+ get() = WidgetAppearance(
+ background = this[BackgroundKey].toEnum(WidgetBackground.TINTED),
+ theme = this[ThemeKey].toEnum(WidgetTheme.FOLLOW_APP),
+ )
+
+/**
+ * This widget's agenda options. A widget placed before per-widget options
+ * existed has none stored, and keeps following the app-wide values in
+ * [defaults] until it is configured.
+ */
+internal fun Preferences.agendaWidgetOptions(defaults: AgendaWidgetOptions) = AgendaWidgetOptions(
+ range = parseAgendaRange(this[AgendaRangeKey], defaults.range),
+ textSize = this[TextSizeKey].toEnum(defaults.textSize),
+ showToday = this[ShowTodayKey] ?: defaults.showToday,
+ pastDisplay = this[PastDisplayKey].toEnum(defaults.pastDisplay),
+)
+
+/** Whether this widget has agenda options of its own, rather than following the app's. */
+internal val Preferences.hasAgendaOptions: Boolean get() = this[AgendaRangeKey] != null
+
+internal fun MutablePreferences.putAppearance(appearance: WidgetAppearance) {
+ this[BackgroundKey] = appearance.background.name
+ this[ThemeKey] = appearance.theme.name
+}
+
+internal fun MutablePreferences.putAgendaOptions(options: AgendaWidgetOptions) {
+ this[AgendaRangeKey] = options.range.storageValue()
+ this[TextSizeKey] = options.textSize.name
+ this[ShowTodayKey] = options.showToday
+ this[PastDisplayKey] = options.pastDisplay.name
+}
+
+internal suspend fun Context.widgetState(id: GlanceId): Preferences =
+ getAppWidgetState(this, PreferencesGlanceStateDefinition, id)
diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/widget/WidgetData.kt b/app/src/main/java/de/jeanlucmakiola/calendula/widget/WidgetData.kt
index 5b5f4a9..dca5a54 100644
--- a/app/src/main/java/de/jeanlucmakiola/calendula/widget/WidgetData.kt
+++ b/app/src/main/java/de/jeanlucmakiola/calendula/widget/WidgetData.kt
@@ -18,6 +18,7 @@ import de.jeanlucmakiola.calendula.ui.agenda.AgendaRange
import de.jeanlucmakiola.calendula.ui.agenda.agendaRange
import de.jeanlucmakiola.calendula.ui.agenda.dayCount
import de.jeanlucmakiola.calendula.ui.agenda.groupAgendaDays
+import de.jeanlucmakiola.floret.prefs.ThemeMode
import de.jeanlucmakiola.floret.prefs.get
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.distinctUntilChanged
@@ -46,26 +47,46 @@ import kotlin.time.Instant
*/
internal const val MONTH_WIDGET_PAD_MONTHS = 3
-/** Every setting the widgets draw with: what the loads read, and what [WidgetRefreshCoordinator] watches. */
+/**
+ * Every app setting the widgets draw with: what the loads read, and what
+ * [WidgetRefreshCoordinator] watches. The agenda fields are only the defaults
+ * for a widget that has no options of its own yet.
+ */
internal data class WidgetSettings(
val soften: Boolean,
val weekStart: WeekStartPref,
val timeFormat: TimeFormatPref,
+ val themeMode: ThemeMode,
val pastDisplay: PastEventDisplay,
val agendaRange: AgendaRange,
val widgetSize: WidgetSize,
val showToday: Boolean,
) {
- /** The part the month widget draws with; the rest is agenda-only. */
- val month: Pair get() = soften to weekStart
+ /** What every widget draws with; the agenda defaults aside. */
+ val shared: SharedWidgetSettings get() = SharedWidgetSettings(soften, weekStart, timeFormat, themeMode)
+
+ val agendaDefaults: AgendaWidgetOptions
+ get() = AgendaWidgetOptions(agendaRange, widgetSize, showToday, pastDisplay)
}
+/** The [WidgetSettings] a configured widget still takes from the app. */
+internal data class SharedWidgetSettings(
+ val soften: Boolean,
+ val weekStart: WeekStartPref,
+ val timeFormat: TimeFormatPref,
+ val themeMode: ThemeMode,
+)
+
+/** A widget's loaded [content], with the app theme a [WidgetTheme.FOLLOW_APP] widget resolves against. */
+internal data class Themed(val content: T, val appTheme: ThemeMode)
+
internal val SettingsPrefs.widgetSettings: Flow
get() = data.map {
WidgetSettings(
soften = it[SettingsKeys.softenCalendarColors],
weekStart = it[SettingsKeys.weekStart],
timeFormat = it[SettingsKeys.timeFormat],
+ themeMode = it[SettingsKeys.themeMode],
pastDisplay = it[SettingsKeys.pastEventDisplay],
agendaRange = it[SettingsKeys.agendaWidgetRange],
widgetSize = it[SettingsKeys.widgetSize],
@@ -97,7 +118,7 @@ sealed interface AgendaWidgetData {
val pastDisplay: PastEventDisplay,
/** "Always show today" (#35): anchor today at the top even with no events left. */
val showToday: Boolean,
- val widgetSize: WidgetSize,
+ val textSize: WidgetSize,
/** Snapshot instant the data was read at, for "has this event ended?" tests. */
val now: Instant,
) : AgendaWidgetData
@@ -121,19 +142,28 @@ sealed interface MonthWidgetSource {
) : MonthWidgetSource
}
+/** [loadAgendaWidgetData] with agenda widget [id]'s own options. */
+internal suspend fun Context.loadAgendaWidget(id: GlanceId): Themed {
+ val settings = widgetEntryPoint().settingsPrefs().widgetSettings.first()
+ val options = widgetState(id).agendaWidgetOptions(settings.agendaDefaults)
+ return Themed(loadAgendaWidgetData(settings, options), settings.themeMode)
+}
+
/**
* One-shot read of the upcoming agenda for the widget. Reuses the app's
* [agendaRange] window and [groupAgendaDays] grouping, and the repository's
* [first]-emitted snapshot already has hidden calendars filtered out.
*/
-internal suspend fun Context.loadAgendaWidgetData(): AgendaWidgetData {
+internal suspend fun Context.loadAgendaWidgetData(
+ settings: WidgetSettings,
+ options: AgendaWidgetOptions,
+): AgendaWidgetData {
if (!hasCalendarPermission()) return AgendaWidgetData.NeedsPermission
val zone = systemZone()
val anchor = today(zone)
val ep = widgetEntryPoint()
- val settings = ep.settingsPrefs().widgetSettings.first()
val weekStart = settings.weekStart.resolveFirstDay(Locale.getDefault())
- val dayCount = settings.agendaRange.dayCount(anchor, weekStart)
+ val dayCount = options.range.dayCount(anchor, weekStart)
val window = agendaRange(anchor, dayCount - 1, zone)
val windowEnd = anchor.plus(dayCount - 1, DateTimeUnit.DAY)
val instances = ep.calendarRepository().instances(window).first()
@@ -142,9 +172,9 @@ internal suspend fun Context.loadAgendaWidgetData(): AgendaWidgetData {
days = groupAgendaDays(anchor, windowEnd, instances, zone),
is24Hour = settings.timeFormat.is24Hour(android.text.format.DateFormat.is24HourFormat(this)),
soften = settings.soften,
- pastDisplay = settings.pastDisplay,
- showToday = settings.showToday,
- widgetSize = settings.widgetSize,
+ pastDisplay = options.pastDisplay,
+ showToday = options.showToday,
+ textSize = options.textSize,
now = Clock.System.now(),
)
}
@@ -167,30 +197,35 @@ internal fun monthWindowCenter(index: Int): Int = Math.floorDiv(index, 3) * 3 +
* stale.
*/
private object MonthSourceCache {
- private class Entry(val center: Int, val stamp: Long, val source: MonthWidgetSource.Ready)
+ private class Entry(val center: Int, val stamp: Long, val source: Themed)
private val entries = ConcurrentHashMap()
- fun get(id: GlanceId, center: Int, stamp: Long): MonthWidgetSource.Ready? =
- entries[id]?.takeIf { it.center == center && it.stamp == stamp && it.source.today == today(systemZone()) }
+ fun get(id: GlanceId, center: Int, stamp: Long): Themed? =
+ entries[id]?.takeIf { it.center == center && it.stamp == stamp && it.source.content.today == today(systemZone()) }
?.source
- fun put(id: GlanceId, center: Int, stamp: Long, source: MonthWidgetSource) {
- if (source is MonthWidgetSource.Ready) entries[id] = Entry(center, stamp, source)
+ fun put(id: GlanceId, center: Int, stamp: Long, source: Themed) {
+ val ready = source.content as? MonthWidgetSource.Ready ?: return
+ entries[id] = Entry(center, stamp, Themed(ready, source.appTheme))
}
}
-/** [loadMonthWidgetSource] for widget [id], served from [MonthSourceCache] while its [stamp] holds. */
-internal suspend fun Context.loadMonthWidgetSource(id: GlanceId, centerIndex: Int, stamp: Long): MonthWidgetSource =
+/** [loadThemedMonthSource] for widget [id], served from [MonthSourceCache] while its [stamp] holds. */
+internal suspend fun Context.loadCachedMonthSource(id: GlanceId, centerIndex: Int, stamp: Long): Themed =
MonthSourceCache.get(id, centerIndex, stamp)
- ?: loadMonthWidgetSource(centerIndex).also { MonthSourceCache.put(id, centerIndex, stamp, it) }
+ ?: loadThemedMonthSource(centerIndex).also { MonthSourceCache.put(id, centerIndex, stamp, it) }
-/** Read backing the month widget's grid for the window around [centerIndex]. */
-private suspend fun Context.loadMonthWidgetSource(centerIndex: Int): MonthWidgetSource {
+/** Read backing the month widget's grid for the window around [centerIndex], with the app theme. */
+internal suspend fun Context.loadThemedMonthSource(centerIndex: Int): Themed {
+ val settings = widgetEntryPoint().settingsPrefs().widgetSettings.first()
+ return Themed(queryMonthSource(centerIndex, settings), settings.themeMode)
+}
+
+private suspend fun Context.queryMonthSource(centerIndex: Int, settings: WidgetSettings): MonthWidgetSource {
if (!hasCalendarPermission()) return MonthWidgetSource.NeedsPermission
val zone = systemZone()
val ep = widgetEntryPoint()
- val settings = ep.settingsPrefs().widgetSettings.first()
val months = (centerIndex - MONTH_WIDGET_PAD_MONTHS)..(centerIndex + MONTH_WIDGET_PAD_MONTHS)
// Padded by the grid's spill into the neighbouring months.
val from = firstDayOfMonthIndex(months.first)
diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/widget/WidgetRedraw.kt b/app/src/main/java/de/jeanlucmakiola/calendula/widget/WidgetRedraw.kt
index 222228d..5425900 100644
--- a/app/src/main/java/de/jeanlucmakiola/calendula/widget/WidgetRedraw.kt
+++ b/app/src/main/java/de/jeanlucmakiola/calendula/widget/WidgetRedraw.kt
@@ -1,6 +1,8 @@
package de.jeanlucmakiola.calendula.widget
import android.app.PendingIntent
+import android.appwidget.AppWidgetManager
+import android.content.ComponentName
import android.content.Context
import android.content.Intent
import android.os.SystemClock
@@ -14,6 +16,8 @@ import de.jeanlucmakiola.calendula.widget.month.MonthWidgetReceiver
import de.jeanlucmakiola.floret.glance.WidgetRefresher
import de.jeanlucmakiola.floret.glance.WidgetRollover
import de.jeanlucmakiola.floret.glance.updatePlaced
+import de.jeanlucmakiola.floret.glance.updateWithRefresh
+import androidx.glance.appwidget.GlanceAppWidgetManager
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.CoroutineStart
@@ -63,6 +67,18 @@ internal suspend fun redrawWidgets(context: Context) {
private suspend fun redrawAgendaWidgets(context: Context) =
AgendaWidget().updatePlaced(context, AgendaWidgetReceiver::class.java, bumpRefreshStamp = true)
+/** Redraws only the agenda widgets with no options of their own, which still follow the app's. */
+private suspend fun redrawUnconfiguredAgendaWidgets(context: Context) {
+ val appContext = context.applicationContext
+ val ids = AppWidgetManager.getInstance(appContext)
+ ?.getAppWidgetIds(ComponentName(appContext, AgendaWidgetReceiver::class.java)) ?: return
+ val glanceManager = GlanceAppWidgetManager(appContext)
+ ids.forEach { appWidgetId ->
+ val glanceId = glanceManager.getGlanceIdBy(appWidgetId)
+ if (!appContext.widgetState(glanceId).hasAgendaOptions) AgendaWidget().updateWithRefresh(appContext, glanceId)
+ }
+}
+
private suspend fun redrawMonthWidgets(context: Context) =
MonthWidget().updatePlaced(context, MonthWidgetReceiver::class.java, bumpRefreshStamp = true)
@@ -80,8 +96,9 @@ internal fun isProviderEcho(): Boolean {
* Redraws the widgets on the app's own calendar writes, visibility changes and
* the settings they draw with, so those don't wait for `PROVIDER_CHANGED`,
* which arrives seconds late or not at all (#420, #229). Changes from outside
- * the app are [WidgetUpdateReceiver]'s. A setting only the agenda widget draws
- * with leaves the month widget alone. Started once from `CalendulaApp`.
+ * the app are [WidgetUpdateReceiver]'s. The app's agenda options only redraw
+ * agenda widgets that have none of their own (#292). Started once from
+ * `CalendulaApp`.
*/
@Singleton
class WidgetRefreshCoordinator @Inject constructor(
@@ -96,14 +113,20 @@ class WidgetRefreshCoordinator @Inject constructor(
fun start() {
if (!started.compareAndSet(false, true)) return
val agenda = WidgetRefresher(scope) { redrawAgendaWidgets(context) }
+ val unconfiguredAgenda = WidgetRefresher(scope) { redrawUnconfiguredAgendaWidgets(context) }
val month = WidgetRefresher(scope) { redrawMonthWidgets(context) }
watch(repository.localChanges) {
lastLocalChange.set(SystemClock.elapsedRealtime())
agenda.request()
month.request()
}
- watch(prefs.widgetSettings.drop(1)) { agenda.request() }
- watch(prefs.widgetSettings.map { it.month }.distinctUntilChanged().drop(1)) { month.request() }
+ watch(prefs.widgetSettings.map { it.shared }.distinctUntilChanged().drop(1)) {
+ agenda.request()
+ month.request()
+ }
+ watch(prefs.widgetSettings.map { it.agendaDefaults }.distinctUntilChanged().drop(1)) {
+ unconfiguredAgenda.request()
+ }
}
private fun watch(changes: Flow<*>, onChange: () -> Unit) {
diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/widget/WidgetTheme.kt b/app/src/main/java/de/jeanlucmakiola/calendula/widget/WidgetTheme.kt
index 3fba7de..8809be4 100644
--- a/app/src/main/java/de/jeanlucmakiola/calendula/widget/WidgetTheme.kt
+++ b/app/src/main/java/de/jeanlucmakiola/calendula/widget/WidgetTheme.kt
@@ -5,23 +5,34 @@ import androidx.glance.text.TextDecoration
import de.jeanlucmakiola.calendula.ui.theme.CalendulaDarkFallback
import de.jeanlucmakiola.calendula.ui.theme.CalendulaLightFallback
import de.jeanlucmakiola.floret.glance.FloretGlanceTheme
+import de.jeanlucmakiola.floret.glance.WidgetSurface
import de.jeanlucmakiola.floret.glance.WidgetThemeMode
+import de.jeanlucmakiola.floret.prefs.ThemeMode
/**
* Glance equivalent of `CalendulaTheme`: Material You on API 31+, the app's
- * hand-tuned fallback schemes below that, following the system's light/dark.
+ * hand-tuned fallback schemes below that, in the light/dark [mode] the widget
+ * was configured with (#292).
*/
@Composable
-fun CalendulaGlanceTheme(content: @Composable () -> Unit) {
+fun CalendulaGlanceTheme(mode: WidgetThemeMode, content: @Composable () -> Unit) {
FloretGlanceTheme(
lightScheme = CalendulaLightFallback,
darkScheme = CalendulaDarkFallback,
dynamicColor = true,
- mode = WidgetThemeMode.SYSTEM,
+ mode = mode,
content = content,
)
}
+/** A widget's root: its configured theme and background around [content]. */
+@Composable
+internal fun CalendulaWidgetRoot(appearance: WidgetAppearance, appTheme: ThemeMode, content: @Composable () -> Unit) {
+ CalendulaGlanceTheme(appearance.theme.resolve(appTheme)) {
+ WidgetSurface(background = appearance.background, content = content)
+ }
+}
+
/**
* Glance's counterpart to the app's `declinedDecoration`: a declined invitation
* reads the same on the home screen as it does inside the app (#180).
diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/widget/agenda/AgendaWidget.kt b/app/src/main/java/de/jeanlucmakiola/calendula/widget/agenda/AgendaWidget.kt
index f027750..1695baa 100644
--- a/app/src/main/java/de/jeanlucmakiola/calendula/widget/agenda/AgendaWidget.kt
+++ b/app/src/main/java/de/jeanlucmakiola/calendula/widget/agenda/AgendaWidget.kt
@@ -16,7 +16,6 @@ import androidx.glance.action.ActionParameters
import androidx.glance.action.clickable
import androidx.glance.appwidget.GlanceAppWidget
import androidx.glance.appwidget.SizeMode
-import androidx.glance.color.ColorProvider
import androidx.glance.appwidget.action.ActionCallback
import androidx.glance.appwidget.action.actionRunCallback
import androidx.glance.appwidget.action.actionStartActivity
@@ -55,9 +54,21 @@ import de.jeanlucmakiola.floret.locale.localizedDateFormatter
import de.jeanlucmakiola.calendula.ui.common.eventAccent
import de.jeanlucmakiola.calendula.ui.common.eventFill
import de.jeanlucmakiola.calendula.widget.AgendaWidgetData
-import de.jeanlucmakiola.calendula.widget.CalendulaGlanceTheme
import de.jeanlucmakiola.calendula.widget.WidgetSize
-import de.jeanlucmakiola.calendula.widget.loadAgendaWidgetData
+import de.jeanlucmakiola.calendula.widget.CalendulaWidgetRoot
+import de.jeanlucmakiola.calendula.widget.WidgetAppearance
+import de.jeanlucmakiola.calendula.widget.loadAgendaWidget
+import de.jeanlucmakiola.calendula.widget.widgetAppearance
+import de.jeanlucmakiola.floret.components.Position
+import de.jeanlucmakiola.floret.components.positionOf
+import de.jeanlucmakiola.floret.glance.LocalWidgetBackground
+import de.jeanlucmakiola.floret.glance.WidgetBackground
+import de.jeanlucmakiola.floret.glance.WidgetGroupedRow
+import de.jeanlucmakiola.floret.glance.themedColor
+import de.jeanlucmakiola.floret.glance.wallpaperBacking
+import de.jeanlucmakiola.floret.prefs.ThemeMode
+import androidx.datastore.preferences.core.Preferences
+import androidx.glance.currentState
import de.jeanlucmakiola.floret.glance.WidgetData
import de.jeanlucmakiola.floret.glance.currentRefreshStamp
import de.jeanlucmakiola.floret.glance.refreshStamp
@@ -89,18 +100,40 @@ class AgendaWidget : GlanceAppWidget() {
override suspend fun provideGlance(context: Context, id: GlanceId) {
val stamp = refreshStamp(context, id)
- val widgetData = WidgetData(context.loadAgendaWidgetData(), stamp)
+ val widgetData = WidgetData(context.loadAgendaWidget(id), stamp)
provideContent {
// A running session never re-runs this preamble, so every redraw
// moves the stamp and the data reloads here instead (#420).
- val data = widgetData.current(currentRefreshStamp()) { context.loadAgendaWidgetData() }
- CalendulaGlanceTheme {
- AgendaWidgetBody(data = data)
- }
+ val loaded = widgetData.current(currentRefreshStamp()) { context.loadAgendaWidget(id) }
+ AgendaWidgetContent(
+ data = loaded.content,
+ appearance = currentState().widgetAppearance,
+ appTheme = loaded.appTheme,
+ )
}
}
}
+/**
+ * The whole agenda widget, shared with its config screen's preview (#292). The
+ * [preview] draws its rows as a plain column, which RemoteViews can show
+ * outside a widget host on every Android version.
+ */
+@Composable
+internal fun AgendaWidgetContent(
+ data: AgendaWidgetData,
+ appearance: WidgetAppearance,
+ appTheme: ThemeMode,
+ preview: Boolean = false,
+) {
+ CalendulaWidgetRoot(appearance, appTheme) {
+ AgendaWidgetBody(data = data, preview = preview)
+ }
+}
+
+/** Rows a preview draws: more than any widget shows at once. */
+private const val PREVIEW_ROWS = 16
+
/** Reloads and redraws the tapped widget (header refresh button). */
class RefreshAgendaAction : ActionCallback {
override suspend fun onAction(context: Context, glanceId: GlanceId, parameters: ActionParameters) {
@@ -119,30 +152,31 @@ private const val MAX_AGENDA_ROWS = 100
/** Flat row model so the [LazyColumn] can mix day headers and events. */
private sealed interface AgendaRow {
- data class Header(val date: LocalDate, val today: LocalDate) : AgendaRow
- data class Event(val date: LocalDate, val event: EventInstance) : AgendaRow
+ val date: LocalDate
+
+ data class Header(override val date: LocalDate, val today: LocalDate) : AgendaRow
+ data class Event(override val date: LocalDate, val event: EventInstance) : AgendaRow
/** "Nothing left today" line under an anchored, event-less today (#35). */
- data class Placeholder(val date: LocalDate) : AgendaRow
+ data class Placeholder(override val date: LocalDate) : AgendaRow
}
@Composable
-private fun AgendaWidgetBody(data: AgendaWidgetData) {
+private fun AgendaWidgetBody(data: AgendaWidgetData, preview: Boolean) {
// The stripe is re-resolved against the system font scale so it tracks the
// sp-sized text beside it instead of drifting at large accessibility settings.
- val size = (data as? AgendaWidgetData.Ready)?.widgetSize ?: WidgetSize.SMALL
+ val size = (data as? AgendaWidgetData.Ready)?.textSize ?: WidgetSize.SMALL
val fontScale = androidx.glance.LocalContext.current.resources.configuration.fontScale
val metrics = metricsFor(size).scaledForFont(fontScale)
Column(
modifier = GlanceModifier
.fillMaxSize()
- .background(GlanceTheme.colors.surface)
.padding(horizontal = 8.dp, vertical = 6.dp),
) {
AgendaHeader(metrics)
Spacer(GlanceModifier.height(4.dp))
when (data) {
AgendaWidgetData.NeedsPermission -> WidgetMessage(R.string.widget_needs_permission, metrics)
- is AgendaWidgetData.Ready -> AgendaRows(data = data, metrics = metrics)
+ is AgendaWidgetData.Ready -> AgendaRows(data = data, metrics = metrics, preview = preview)
}
}
}
@@ -151,6 +185,7 @@ private fun AgendaWidgetBody(data: AgendaWidgetData) {
private fun AgendaRows(
data: AgendaWidgetData.Ready,
metrics: AgendaMetrics,
+ preview: Boolean,
) {
val pastDisplay = data.pastDisplay
// Drop finished events (and any day they leave empty) when the mode is
@@ -189,23 +224,59 @@ private fun AgendaRows(
// stranded with nothing under it.
.take(MAX_AGENDA_ROWS)
.dropLastWhile { it is AgendaRow.Header }
- LazyColumn(modifier = GlanceModifier.fillMaxSize()) {
- items(rows.size) { index ->
- when (val row = rows[index]) {
- is AgendaRow.Header -> DayHeaderRow(row.date, row.today, metrics)
- is AgendaRow.Placeholder -> PlaceholderRow(row.date, metrics)
- is AgendaRow.Event -> EventRow(
- event = row.event,
- day = row.date,
- soften = data.soften,
- is24Hour = data.is24Hour,
- dimmed = pastDisplay == PastEventDisplay.DIM &&
- row.event.hasEnded(data.now),
- metrics = metrics,
- )
+ // On a transparent widget each day becomes one card of grouped rows,
+ // so it stays readable over the wallpaper (#292).
+ val positions = if (LocalWidgetBackground.current == WidgetBackground.TRANSPARENT) dayPositions(rows) else null
+ val item: @Composable (Int) -> Unit = { index ->
+ val row = rows[index]
+ if (positions != null) {
+ WidgetGroupedRow(
+ position = positions[index],
+ modifier = if (row is AgendaRow.Header && index > 0) GlanceModifier.padding(top = 6.dp) else GlanceModifier,
+ color = GlanceTheme.colors.widgetBackground,
+ ) {
+ AgendaRowContent(row, data, metrics)
}
+ } else {
+ AgendaRowContent(row, data, metrics)
}
}
+ if (preview) {
+ Column(modifier = GlanceModifier.fillMaxSize()) {
+ repeat(minOf(rows.size, PREVIEW_ROWS)) { item(it) }
+ }
+ } else {
+ LazyColumn(modifier = GlanceModifier.fillMaxSize()) {
+ items(rows.size) { item(it) }
+ }
+ }
+ }
+}
+
+/** Where each row sits among the rows of its day, for the grouped-card shapes. */
+private fun dayPositions(rows: List): List = buildList {
+ var start = 0
+ for (end in 1..rows.size) {
+ if (end == rows.size || rows[end].date != rows[start].date) {
+ for (i in start until end) add(positionOf(i - start, end - start))
+ start = end
+ }
+ }
+}
+
+@Composable
+private fun AgendaRowContent(row: AgendaRow, data: AgendaWidgetData.Ready, metrics: AgendaMetrics) {
+ when (row) {
+ is AgendaRow.Header -> DayHeaderRow(row.date, row.today, metrics)
+ is AgendaRow.Placeholder -> PlaceholderRow(row.date, metrics)
+ is AgendaRow.Event -> EventRow(
+ event = row.event,
+ day = row.date,
+ soften = data.soften,
+ is24Hour = data.is24Hour,
+ dimmed = data.pastDisplay == PastEventDisplay.DIM && row.event.hasEnded(data.now),
+ metrics = metrics,
+ )
}
}
@@ -213,7 +284,7 @@ private fun AgendaRows(
private fun AgendaHeader(metrics: AgendaMetrics) {
val context = androidx.glance.LocalContext.current
Row(
- modifier = GlanceModifier.fillMaxWidth().padding(horizontal = 4.dp),
+ modifier = GlanceModifier.fillMaxWidth().wallpaperBacking().padding(horizontal = 4.dp),
verticalAlignment = Alignment.CenterVertically,
) {
// Tap the "Upcoming" title to open the app on the user's default view
@@ -340,7 +411,7 @@ private fun EventRow(
fun stripe(dark: Boolean) = eventAccent(event.color, dark, soften).let {
if (dimmed) it.copy(alpha = EventDimAlpha) else it
}
- val stripeColor = ColorProvider(day = stripe(dark = false), night = stripe(dark = true))
+ val stripeColor = themedColor(day = stripe(dark = false), night = stripe(dark = true))
val titleColor = if (dimmed) GlanceTheme.colors.onSurfaceVariant else GlanceTheme.colors.onSurface
Row(
modifier = GlanceModifier
@@ -396,6 +467,7 @@ private fun WidgetMessage(resId: Int, metrics: AgendaMetrics) {
Text(
text = context.getString(resId),
style = TextStyle(color = GlanceTheme.colors.onSurfaceVariant, fontSize = metrics.message),
+ modifier = GlanceModifier.wallpaperBacking().padding(horizontal = 12.dp, vertical = 8.dp),
)
}
}
diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/widget/agenda/AgendaWidgetReceiver.kt b/app/src/main/java/de/jeanlucmakiola/calendula/widget/agenda/AgendaWidgetReceiver.kt
index 2ed8638..ee48a12 100644
--- a/app/src/main/java/de/jeanlucmakiola/calendula/widget/agenda/AgendaWidgetReceiver.kt
+++ b/app/src/main/java/de/jeanlucmakiola/calendula/widget/agenda/AgendaWidgetReceiver.kt
@@ -7,6 +7,7 @@ import androidx.glance.appwidget.GlanceAppWidget
import androidx.glance.appwidget.GlanceAppWidgetReceiver
import de.jeanlucmakiola.calendula.widget.syncWidgetRollover
import de.jeanlucmakiola.floret.glance.reloadOnUpdate
+import de.jeanlucmakiola.floret.glance.restoreState
/**
* Host-facing receiver for the agenda widget. Declared in the manifest with the
@@ -31,6 +32,11 @@ class AgendaWidgetReceiver : GlanceAppWidgetReceiver() {
syncWidgetRollover(context)
}
+ /** A backup restore or device transfer renumbered the widgets: keep each one's settings (#292). */
+ override fun onRestored(context: Context, oldWidgetIds: IntArray, newWidgetIds: IntArray) {
+ restoreState(context, oldWidgetIds, newWidgetIds)
+ }
+
/**
* Self-heal on the system's own `updatePeriodMillis` wake-up — see
* [de.jeanlucmakiola.calendula.widget.month.MonthWidgetReceiver.onUpdate].
diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/widget/config/PlacedWidgets.kt b/app/src/main/java/de/jeanlucmakiola/calendula/widget/config/PlacedWidgets.kt
new file mode 100644
index 0000000..4425ac4
--- /dev/null
+++ b/app/src/main/java/de/jeanlucmakiola/calendula/widget/config/PlacedWidgets.kt
@@ -0,0 +1,31 @@
+package de.jeanlucmakiola.calendula.widget.config
+
+import android.appwidget.AppWidgetManager
+import android.content.ComponentName
+import android.content.Context
+import androidx.glance.appwidget.GlanceAppWidgetManager
+import de.jeanlucmakiola.calendula.widget.WidgetAppearance
+import de.jeanlucmakiola.calendula.widget.widgetAppearance
+import de.jeanlucmakiola.calendula.widget.widgetState
+
+/** One of the app's widgets on the home screen, for the settings list. */
+data class PlacedWidget(val appWidgetId: Int, val kind: WidgetKind, val appearance: WidgetAppearance)
+
+/**
+ * Every placed widget, agenda ones first, each in placement order. An id the
+ * host bound but never laid out (a placement it abandoned without freeing the
+ * id) has no size yet and is left out, as is one removed while this reads.
+ */
+suspend fun Context.placedWidgets(): List {
+ val manager = AppWidgetManager.getInstance(this) ?: return emptyList()
+ val glanceManager = GlanceAppWidgetManager(this)
+ return WidgetKind.entries.flatMap { kind ->
+ manager.getAppWidgetIds(ComponentName(this, kind.receiver)).sorted().mapNotNull { id ->
+ if (manager.getAppWidgetOptions(id).getInt(AppWidgetManager.OPTION_APPWIDGET_MIN_WIDTH) <= 0) {
+ return@mapNotNull null
+ }
+ val glanceId = runCatching { glanceManager.getGlanceIdBy(id) }.getOrNull() ?: return@mapNotNull null
+ PlacedWidget(id, kind, widgetState(glanceId).widgetAppearance)
+ }
+ }
+}
diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/widget/config/WidgetConfigActivity.kt b/app/src/main/java/de/jeanlucmakiola/calendula/widget/config/WidgetConfigActivity.kt
new file mode 100644
index 0000000..12cddfb
--- /dev/null
+++ b/app/src/main/java/de/jeanlucmakiola/calendula/widget/config/WidgetConfigActivity.kt
@@ -0,0 +1,130 @@
+package de.jeanlucmakiola.calendula.widget.config
+
+import android.appwidget.AppWidgetManager
+import android.content.Context
+import android.content.Intent
+import android.os.Bundle
+import android.widget.Toast
+import androidx.activity.ComponentActivity
+import androidx.activity.compose.setContent
+import androidx.activity.enableEdgeToEdge
+import androidx.activity.viewModels
+import androidx.compose.foundation.isSystemInDarkTheme
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.remember
+import androidx.compose.ui.platform.LocalContext
+import androidx.lifecycle.Lifecycle
+import androidx.lifecycle.compose.collectAsStateWithLifecycle
+import androidx.lifecycle.lifecycleScope
+import androidx.lifecycle.repeatOnLifecycle
+import dagger.hilt.android.AndroidEntryPoint
+import de.jeanlucmakiola.calendula.R
+import de.jeanlucmakiola.calendula.data.prefs.SettingsKeys
+import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
+import de.jeanlucmakiola.calendula.domain.FontRole
+import de.jeanlucmakiola.calendula.ui.theme.AppFontSettings
+import de.jeanlucmakiola.calendula.ui.theme.CalendulaTheme
+import de.jeanlucmakiola.calendula.ui.theme.WindowThemeCache
+import de.jeanlucmakiola.calendula.ui.theme.calendulaTypography
+import de.jeanlucmakiola.calendula.ui.theme.resolveFontFamily
+import de.jeanlucmakiola.floret.prefs.ThemeMode
+import de.jeanlucmakiola.floret.prefs.get
+import de.jeanlucmakiola.floret.prefs.isDark
+import de.jeanlucmakiola.floret.prefs.isPureBlack
+import kotlinx.coroutines.flow.map
+import kotlinx.coroutines.launch
+import javax.inject.Inject
+
+/**
+ * A widget's config screen (#292): opened by the launcher when the widget is
+ * placed, from its "Edit widget" action on Android 12+, and from the app's
+ * widget settings. Shows over the wallpaper, so a transparent widget previews
+ * against the real home screen.
+ */
+@AndroidEntryPoint
+class WidgetConfigActivity : ComponentActivity() {
+
+ @Inject lateinit var settingsPrefs: SettingsPrefs
+
+ private val viewModel: WidgetConfigViewModel by viewModels()
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ val appWidgetId = viewModel.appWidgetId
+ // Backing out of a fresh placement leaves no unconfigured widget behind.
+ setResult(RESULT_CANCELED, result(appWidgetId))
+ if (appWidgetId == AppWidgetManager.INVALID_APPWIDGET_ID) {
+ finish()
+ return
+ }
+ enableEdgeToEdge()
+ lifecycleScope.launch {
+ repeatOnLifecycle(Lifecycle.State.STARTED) {
+ viewModel.events.collect { event ->
+ when (event) {
+ WidgetConfigEvent.SAVED -> {
+ setResult(RESULT_OK, result(appWidgetId))
+ finish()
+ }
+ WidgetConfigEvent.SAVE_FAILED ->
+ Toast.makeText(this@WidgetConfigActivity, R.string.widget_config_save_failed, Toast.LENGTH_SHORT).show()
+ }
+ }
+ }
+ }
+ val fontSettings = settingsPrefs.data.map {
+ AppFontSettings(
+ brand = it[SettingsKeys.brandFont],
+ plain = it[SettingsKeys.plainFont],
+ brandStamp = it[SettingsKeys.brandFontStamp],
+ plainStamp = it[SettingsKeys.plainFontStamp],
+ )
+ }
+ // Seeded from MainActivity's mirrored copy, so an app theme override
+ // shows from the first frame.
+ val cachedTheme = WindowThemeCache.read(this) ?: ThemeMode.SYSTEM
+ val cachedDynamicColor = WindowThemeCache.readDynamicColor(this) ?: true
+ setContent {
+ val themeMode by settingsPrefs.themeMode.collectAsStateWithLifecycle(cachedTheme)
+ val dynamicColor by settingsPrefs.dynamicColor.collectAsStateWithLifecycle(cachedDynamicColor)
+ val fonts by fontSettings.collectAsStateWithLifecycle(initialValue = null)
+ val context = LocalContext.current
+ // Nothing until the font choice is known, rather than a frame in the default one.
+ val typography = remember(fonts, context) {
+ fonts?.let {
+ calendulaTypography(
+ brand = resolveFontFamily(it.brand, FontRole.BRAND, context),
+ plain = resolveFontFamily(it.plain, FontRole.PLAIN, context),
+ )
+ }
+ } ?: return@setContent
+ CalendulaTheme(
+ darkTheme = themeMode.isDark(isSystemInDarkTheme()),
+ dynamicColor = dynamicColor,
+ pureBlack = themeMode.isPureBlack,
+ typography = typography,
+ ) {
+ val state by viewModel.state.collectAsStateWithLifecycle()
+ if (state is WidgetConfigUiState.Missing) LaunchedEffect(Unit) { finish() }
+ WidgetConfigScreen(
+ state = state,
+ onClose = ::finish,
+ onSave = viewModel::save,
+ onRetry = viewModel::retry,
+ onAppearance = viewModel::setAppearance,
+ onAgendaOptions = viewModel::setAgendaOptions,
+ )
+ }
+ }
+ }
+
+ private fun result(appWidgetId: Int) = Intent().putExtra(AppWidgetManager.EXTRA_APPWIDGET_ID, appWidgetId)
+
+ companion object {
+ /** Opens placed widget [appWidgetId]'s config screen from inside the app. */
+ fun intent(context: Context, appWidgetId: Int): Intent =
+ Intent(context, WidgetConfigActivity::class.java)
+ .putExtra(AppWidgetManager.EXTRA_APPWIDGET_ID, appWidgetId)
+ }
+}
diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/widget/config/WidgetConfigScreen.kt b/app/src/main/java/de/jeanlucmakiola/calendula/widget/config/WidgetConfigScreen.kt
new file mode 100644
index 0000000..603e29e
--- /dev/null
+++ b/app/src/main/java/de/jeanlucmakiola/calendula/widget/config/WidgetConfigScreen.kt
@@ -0,0 +1,328 @@
+package de.jeanlucmakiola.calendula.widget.config
+
+import androidx.compose.foundation.background
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.navigationBarsPadding
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.rememberScrollState
+import androidx.compose.foundation.verticalScroll
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.Close
+import androidx.compose.material3.Button
+import androidx.compose.material3.CircularProgressIndicator
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.FilledTonalButton
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Text
+import androidx.compose.material3.TopAppBar
+import androidx.compose.material3.TopAppBarDefaults
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.saveable.rememberSaveable
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.style.TextAlign
+import androidx.compose.ui.text.style.TextOverflow
+import androidx.compose.ui.unit.DpSize
+import androidx.compose.ui.unit.dp
+import de.jeanlucmakiola.calendula.R
+import de.jeanlucmakiola.calendula.data.prefs.PastEventDisplay
+import de.jeanlucmakiola.calendula.ui.common.AgendaRangePicker
+import de.jeanlucmakiola.calendula.ui.common.agendaRangeLabel
+import de.jeanlucmakiola.calendula.ui.settings.pastEventDisplayLabelRes
+import de.jeanlucmakiola.calendula.widget.AgendaWidgetOptions
+import de.jeanlucmakiola.calendula.widget.WidgetAppearance
+import de.jeanlucmakiola.calendula.widget.WidgetSize
+import de.jeanlucmakiola.calendula.widget.WidgetTheme
+import de.jeanlucmakiola.calendula.widget.agenda.AgendaWidgetContent
+import de.jeanlucmakiola.calendula.widget.agenda.metricsFor
+import de.jeanlucmakiola.calendula.widget.month.MonthWidgetContent
+import de.jeanlucmakiola.floret.components.GroupedChoiceRow
+import de.jeanlucmakiola.floret.components.GroupedHint
+import de.jeanlucmakiola.floret.components.GroupedRow
+import de.jeanlucmakiola.floret.components.GroupedSectionHeader
+import de.jeanlucmakiola.floret.components.GroupedSwitchRow
+import de.jeanlucmakiola.floret.components.OptionPicker
+import de.jeanlucmakiola.floret.components.Position
+import de.jeanlucmakiola.floret.glance.GlanceWidgetPreview
+import de.jeanlucmakiola.floret.glance.WallpaperWindow
+import de.jeanlucmakiola.floret.glance.WidgetBackground
+import kotlin.math.roundToInt
+
+private val PreviewMaxHeight = 280.dp
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+internal fun WidgetConfigScreen(
+ state: WidgetConfigUiState,
+ onClose: () -> Unit,
+ onSave: () -> Unit,
+ onRetry: () -> Unit,
+ onAppearance: (WidgetAppearance) -> Unit,
+ onAgendaOptions: (AgendaWidgetOptions) -> Unit,
+) {
+ val ready = state as? WidgetConfigUiState.Ready
+ val surface = MaterialTheme.colorScheme.surface
+ Column(modifier = Modifier.fillMaxSize()) {
+ TopAppBar(
+ title = {
+ Text(
+ text = ready?.let { stringResource(it.kind.label) }.orEmpty(),
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis,
+ )
+ },
+ navigationIcon = {
+ IconButton(onClick = onClose) {
+ Icon(Icons.Default.Close, contentDescription = stringResource(R.string.event_edit_close))
+ }
+ },
+ actions = {
+ Button(
+ onClick = onSave,
+ enabled = ready != null && !ready.saving,
+ modifier = Modifier.padding(end = 8.dp),
+ ) {
+ Text(stringResource(R.string.dialog_save))
+ }
+ },
+ colors = TopAppBarDefaults.topAppBarColors(containerColor = surface),
+ )
+ when (state) {
+ WidgetConfigUiState.Loading, WidgetConfigUiState.Missing -> Box(
+ modifier = Modifier.fillMaxSize().background(surface),
+ contentAlignment = Alignment.Center,
+ ) {
+ CircularProgressIndicator()
+ }
+ WidgetConfigUiState.Failed -> Column(
+ modifier = Modifier.fillMaxSize().background(surface).padding(24.dp),
+ verticalArrangement = Arrangement.Center,
+ horizontalAlignment = Alignment.CenterHorizontally,
+ ) {
+ Text(
+ text = stringResource(R.string.widget_config_load_failed),
+ style = MaterialTheme.typography.bodyLarge,
+ textAlign = TextAlign.Center,
+ )
+ Spacer(Modifier.height(16.dp))
+ FilledTonalButton(onClick = onRetry) { Text(stringResource(R.string.state_retry)) }
+ }
+ is WidgetConfigUiState.Ready -> {
+ WallpaperWindow(color = surface) {
+ val maxSize = DpSize(maxWidth, PreviewMaxHeight)
+ when (val preview = state.preview) {
+ null -> Box(
+ modifier = Modifier.padding(vertical = 16.dp).height(maxSize.height / 2),
+ contentAlignment = Alignment.Center,
+ ) {
+ if (state.previewFailed) PreviewFailed() else CircularProgressIndicator()
+ }
+ else -> GlanceWidgetPreview(
+ size = state.placedSize,
+ maxSize = maxSize,
+ preview,
+ state.appearance,
+ state.appTheme,
+ modifier = Modifier.padding(vertical = 16.dp),
+ placeholder = { CircularProgressIndicator() },
+ failure = { PreviewFailed() },
+ ) {
+ when (preview) {
+ is WidgetPreviewData.Agenda ->
+ AgendaWidgetContent(preview.data, state.appearance, state.appTheme, preview = true)
+ is WidgetPreviewData.Month ->
+ MonthWidgetContent(preview.source, preview.index, state.appearance, state.appTheme)
+ }
+ }
+ }
+ }
+ ConfigOptions(
+ state = state,
+ onAppearance = onAppearance,
+ onAgendaOptions = onAgendaOptions,
+ modifier = Modifier
+ .weight(1f)
+ .background(surface),
+ )
+ }
+ }
+ }
+}
+
+@Composable
+private fun PreviewFailed() {
+ Text(
+ text = stringResource(R.string.widget_config_preview_failed),
+ style = MaterialTheme.typography.bodyMedium,
+ color = MaterialTheme.colorScheme.onSurface,
+ textAlign = TextAlign.Center,
+ modifier = Modifier
+ .background(MaterialTheme.colorScheme.surface, MaterialTheme.shapes.large)
+ .padding(horizontal = 16.dp, vertical = 8.dp),
+ )
+}
+
+internal val WidgetKind.label: Int
+ get() = when (this) {
+ WidgetKind.AGENDA -> R.string.widget_agenda_label
+ WidgetKind.MONTH -> R.string.widget_month_label
+ }
+
+@Composable
+private fun ConfigOptions(
+ state: WidgetConfigUiState.Ready,
+ onAppearance: (WidgetAppearance) -> Unit,
+ onAgendaOptions: (AgendaWidgetOptions) -> Unit,
+ modifier: Modifier = Modifier,
+) {
+ Column(
+ modifier = modifier
+ .verticalScroll(rememberScrollState())
+ .navigationBarsPadding()
+ .padding(bottom = 16.dp),
+ ) {
+ GroupedSectionHeader(stringResource(R.string.settings_section_appearance))
+ GroupedChoiceRow(
+ title = stringResource(R.string.widget_config_background),
+ options = WidgetBackground.entries,
+ selected = state.appearance.background,
+ label = { backgroundLabel(it) },
+ onSelect = { onAppearance(state.appearance.copy(background = it)) },
+ position = Position.Top,
+ )
+ GroupedChoiceRow(
+ title = stringResource(R.string.settings_theme),
+ options = WidgetTheme.entries,
+ selected = state.appearance.theme,
+ label = { themeLabel(it) },
+ onSelect = { onAppearance(state.appearance.copy(theme = it)) },
+ position = Position.Bottom,
+ )
+ state.agenda?.let { options ->
+ AgendaOptions(options = options, state = state, onChange = onAgendaOptions)
+ }
+ }
+}
+
+@Composable
+private fun AgendaOptions(
+ options: AgendaWidgetOptions,
+ state: WidgetConfigUiState.Ready,
+ onChange: (AgendaWidgetOptions) -> Unit,
+) {
+ var showRange by rememberSaveable { mutableStateOf(false) }
+ var showSize by rememberSaveable { mutableStateOf(false) }
+ var showPast by rememberSaveable { mutableStateOf(false) }
+
+ GroupedSectionHeader(stringResource(R.string.widget_config_content))
+ GroupedRow(
+ title = stringResource(R.string.widget_config_range),
+ summary = agendaRangeLabel(options.range),
+ position = Position.Top,
+ onClick = { showRange = true },
+ )
+ GroupedRow(
+ title = stringResource(R.string.widget_config_text_size),
+ summary = widgetSizeLabel(options.textSize),
+ position = Position.Middle,
+ onClick = { showSize = true },
+ )
+ GroupedRow(
+ title = stringResource(R.string.settings_past_events),
+ summary = stringResource(pastEventDisplayLabelRes(options.pastDisplay)),
+ position = Position.Middle,
+ onClick = { showPast = true },
+ )
+ GroupedSwitchRow(
+ title = stringResource(R.string.settings_agenda_show_today),
+ checked = options.showToday,
+ onCheckedChange = { onChange(options.copy(showToday = it)) },
+ position = Position.Bottom,
+ )
+
+ if (showRange) {
+ AgendaRangePicker(
+ title = stringResource(R.string.widget_config_range),
+ description = stringResource(R.string.settings_agenda_widget_range_hint),
+ selected = options.range,
+ weekStart = state.weekStart,
+ onSelect = { onChange(options.copy(range = it)) },
+ onDismiss = { showRange = false },
+ )
+ }
+ if (showSize) {
+ OptionPicker(
+ title = stringResource(R.string.widget_config_text_size),
+ header = { GroupedHint(stringResource(R.string.settings_widget_size_hint), verticalPadding = 8.dp) },
+ predictiveBack = true,
+ options = WidgetSize.entries,
+ selected = options.textSize,
+ label = { widgetSizeLabel(it) },
+ summary = { widgetSizeSummary(it) },
+ onSelect = { onChange(options.copy(textSize = it)) },
+ onDismiss = { showSize = false },
+ )
+ }
+ if (showPast) {
+ OptionPicker(
+ title = stringResource(R.string.settings_past_events),
+ header = { GroupedHint(stringResource(R.string.settings_past_events_hint), verticalPadding = 8.dp) },
+ predictiveBack = true,
+ options = PastEventDisplay.entries,
+ selected = options.pastDisplay,
+ label = { stringResource(pastEventDisplayLabelRes(it)) },
+ onSelect = { onChange(options.copy(pastDisplay = it)) },
+ onDismiss = { showPast = false },
+ )
+ }
+}
+
+@Composable
+internal fun backgroundLabel(background: WidgetBackground): String = stringResource(
+ when (background) {
+ WidgetBackground.SOLID -> R.string.widget_config_background_solid
+ WidgetBackground.TINTED -> R.string.widget_config_background_tinted
+ WidgetBackground.TRANSPARENT -> R.string.widget_config_background_transparent
+ },
+)
+
+@Composable
+internal fun themeLabel(theme: WidgetTheme): String = stringResource(
+ when (theme) {
+ WidgetTheme.FOLLOW_APP -> R.string.widget_config_theme_follow_app
+ WidgetTheme.LIGHT -> R.string.settings_theme_light
+ WidgetTheme.DARK -> R.string.settings_theme_dark
+ },
+)
+
+/**
+ * What a size step does, as a percentage of the smallest one's event text.
+ * Derived from the widget's own [metricsFor] table rather than hardcoded.
+ */
+@Composable
+private fun widgetSizeSummary(size: WidgetSize): String {
+ val base = metricsFor(WidgetSize.SMALL).eventTitle.value
+ val percent = (metricsFor(size).eventTitle.value / base * 100f).roundToInt()
+ return stringResource(R.string.settings_widget_size_summary, percent)
+}
+
+@Composable
+private fun widgetSizeLabel(size: WidgetSize): String = stringResource(
+ when (size) {
+ WidgetSize.SMALL -> R.string.settings_widget_size_small
+ WidgetSize.MEDIUM -> R.string.settings_widget_size_medium
+ WidgetSize.LARGE -> R.string.settings_widget_size_large
+ WidgetSize.EXTRA_LARGE -> R.string.settings_widget_size_extra_large
+ },
+)
diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/widget/config/WidgetConfigViewModel.kt b/app/src/main/java/de/jeanlucmakiola/calendula/widget/config/WidgetConfigViewModel.kt
new file mode 100644
index 0000000..71ac45a
--- /dev/null
+++ b/app/src/main/java/de/jeanlucmakiola/calendula/widget/config/WidgetConfigViewModel.kt
@@ -0,0 +1,242 @@
+package de.jeanlucmakiola.calendula.widget.config
+
+import android.appwidget.AppWidgetManager
+import android.content.Context
+import androidx.compose.ui.unit.DpSize
+import androidx.compose.ui.unit.dp
+import androidx.glance.appwidget.GlanceAppWidget
+import androidx.glance.appwidget.GlanceAppWidgetManager
+import androidx.lifecycle.SavedStateHandle
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.viewModelScope
+import dagger.hilt.android.lifecycle.HiltViewModel
+import dagger.hilt.android.qualifiers.ApplicationContext
+import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
+import de.jeanlucmakiola.calendula.data.prefs.resolveFirstDay
+import de.jeanlucmakiola.calendula.widget.AgendaWidgetData
+import de.jeanlucmakiola.calendula.widget.AgendaWidgetOptions
+import de.jeanlucmakiola.calendula.widget.MonthWidgetSource
+import de.jeanlucmakiola.calendula.widget.WidgetAppearance
+import de.jeanlucmakiola.calendula.widget.agenda.AgendaWidget
+import de.jeanlucmakiola.calendula.widget.agenda.AgendaWidgetReceiver
+import de.jeanlucmakiola.calendula.widget.loadAgendaWidgetData
+import de.jeanlucmakiola.calendula.widget.loadThemedMonthSource
+import de.jeanlucmakiola.calendula.widget.month.MonthWidget
+import de.jeanlucmakiola.calendula.widget.month.MonthWidgetReceiver
+import de.jeanlucmakiola.calendula.widget.monthIndexOf
+import de.jeanlucmakiola.calendula.widget.monthWindowCenter
+import de.jeanlucmakiola.calendula.widget.agendaWidgetOptions
+import de.jeanlucmakiola.calendula.widget.putAgendaOptions
+import de.jeanlucmakiola.calendula.widget.putAppearance
+import de.jeanlucmakiola.calendula.widget.systemZone
+import de.jeanlucmakiola.calendula.widget.today
+import de.jeanlucmakiola.calendula.widget.widgetAppearance
+import de.jeanlucmakiola.calendula.widget.widgetSettings
+import de.jeanlucmakiola.calendula.widget.widgetState
+import de.jeanlucmakiola.floret.glance.updateWithRefresh
+import de.jeanlucmakiola.floret.prefs.ThemeMode
+import javax.inject.Inject
+import kotlinx.coroutines.CancellationException
+import kotlinx.coroutines.Job
+import kotlinx.coroutines.channels.Channel
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.receiveAsFlow
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.asStateFlow
+import kotlinx.coroutines.flow.first
+import kotlinx.coroutines.flow.update
+import kotlinx.coroutines.launch
+import kotlinx.datetime.DayOfWeek
+import java.util.Locale
+
+/** The widgets a config screen can edit, told apart by their receiver. */
+enum class WidgetKind(val receiver: Class<*>) {
+ AGENDA(AgendaWidgetReceiver::class.java),
+ MONTH(MonthWidgetReceiver::class.java),
+ ;
+
+ fun widget(): GlanceAppWidget = when (this) {
+ AGENDA -> AgendaWidget()
+ MONTH -> MonthWidget()
+ }
+
+ companion object {
+ fun of(context: Context, appWidgetId: Int): WidgetKind? {
+ val provider = AppWidgetManager.getInstance(context)?.getAppWidgetInfo(appWidgetId)?.provider ?: return null
+ return entries.firstOrNull { it.receiver.name == provider.className }
+ }
+ }
+}
+
+/** What the preview draws, loaded with the draft's options. */
+sealed interface WidgetPreviewData {
+ data class Agenda(val data: AgendaWidgetData) : WidgetPreviewData
+ data class Month(val source: MonthWidgetSource, val index: Int) : WidgetPreviewData
+}
+
+sealed interface WidgetConfigUiState {
+ data object Loading : WidgetConfigUiState
+
+ /** The widget is gone, or isn't one of ours; the screen closes. */
+ data object Missing : WidgetConfigUiState
+
+ /** Its stored options couldn't be read. */
+ data object Failed : WidgetConfigUiState
+
+ data class Ready(
+ val kind: WidgetKind,
+ val appearance: WidgetAppearance,
+ /** Null for a widget with no content options. */
+ val agenda: AgendaWidgetOptions?,
+ val appTheme: ThemeMode,
+ val weekStart: DayOfWeek,
+ /** The widget's size on the home screen, which the preview is laid out at. */
+ val placedSize: DpSize,
+ /** Null until the first preview load lands. */
+ val preview: WidgetPreviewData?,
+ /** The first preview load failed, so there's nothing to show. */
+ val previewFailed: Boolean = false,
+ val saving: Boolean = false,
+ ) : WidgetConfigUiState
+}
+
+/** One-time outcomes of a save, for the screen that's showing when they land. */
+enum class WidgetConfigEvent { SAVED, SAVE_FAILED }
+
+/**
+ * Backs [WidgetConfigActivity] (#292): the placed widget's stored options as an
+ * editable draft, a preview loaded with that draft, and the save that writes it
+ * into the widget's Glance state and redraws it.
+ */
+@HiltViewModel
+class WidgetConfigViewModel @Inject constructor(
+ @ApplicationContext private val context: Context,
+ private val prefs: SettingsPrefs,
+ savedState: SavedStateHandle,
+) : ViewModel() {
+
+ val appWidgetId: Int = savedState[AppWidgetManager.EXTRA_APPWIDGET_ID] ?: AppWidgetManager.INVALID_APPWIDGET_ID
+
+ private val _state = MutableStateFlow(WidgetConfigUiState.Loading)
+ val state: StateFlow = _state.asStateFlow()
+
+ private val _events = Channel(Channel.BUFFERED)
+ val events: Flow = _events.receiveAsFlow()
+
+ private var previewJob: Job? = null
+
+ init {
+ viewModelScope.launch { load() }
+ }
+
+ fun retry() {
+ if (_state.value != WidgetConfigUiState.Failed) return
+ _state.value = WidgetConfigUiState.Loading
+ viewModelScope.launch { load() }
+ }
+
+ private suspend fun load() {
+ val kind = WidgetKind.of(context, appWidgetId)
+ if (kind == null) {
+ _state.value = WidgetConfigUiState.Missing
+ return
+ }
+ _state.value = try {
+ val stored = context.widgetState(GlanceAppWidgetManager(context).getGlanceIdBy(appWidgetId))
+ val settings = prefs.widgetSettings.first()
+ WidgetConfigUiState.Ready(
+ kind = kind,
+ appearance = stored.widgetAppearance,
+ agenda = if (kind == WidgetKind.AGENDA) stored.agendaWidgetOptions(settings.agendaDefaults) else null,
+ appTheme = settings.themeMode,
+ weekStart = settings.weekStart.resolveFirstDay(Locale.getDefault()),
+ placedSize = placedSize(kind),
+ preview = null,
+ )
+ } catch (e: CancellationException) {
+ throw e
+ } catch (_: IllegalArgumentException) {
+ // getGlanceIdBy: the widget was removed meanwhile.
+ WidgetConfigUiState.Missing
+ } catch (_: Exception) {
+ WidgetConfigUiState.Failed
+ }
+ reloadPreview()
+ }
+
+ /** The size the launcher placed the widget at (portrait), or a default before it has reported one. */
+ private fun placedSize(kind: WidgetKind): DpSize {
+ val options = AppWidgetManager.getInstance(context)?.getAppWidgetOptions(appWidgetId)
+ val width = options?.getInt(AppWidgetManager.OPTION_APPWIDGET_MIN_WIDTH) ?: 0
+ val height = options?.getInt(AppWidgetManager.OPTION_APPWIDGET_MAX_HEIGHT) ?: 0
+ if (width > 0 && height > 0) return DpSize(width.dp, height.dp)
+ return when (kind) {
+ WidgetKind.AGENDA -> DpSize(260.dp, 280.dp)
+ WidgetKind.MONTH -> DpSize(320.dp, 340.dp)
+ }
+ }
+
+ fun setAppearance(appearance: WidgetAppearance) = edit { it.copy(appearance = appearance) }
+
+ fun setAgendaOptions(options: AgendaWidgetOptions) {
+ edit { it.copy(agenda = options) }
+ reloadPreview()
+ }
+
+ private fun edit(change: (WidgetConfigUiState.Ready) -> WidgetConfigUiState.Ready) {
+ _state.update { if (it is WidgetConfigUiState.Ready) change(it) else it }
+ }
+
+ private fun reloadPreview() {
+ val ready = _state.value as? WidgetConfigUiState.Ready ?: return
+ previewJob?.cancel()
+ previewJob = viewModelScope.launch {
+ val preview = try {
+ loadPreview(ready)
+ } catch (e: CancellationException) {
+ throw e
+ } catch (_: Exception) {
+ // The options stay editable; the preview keeps what it had.
+ edit { it.copy(previewFailed = it.preview == null) }
+ return@launch
+ }
+ edit { it.copy(preview = preview, previewFailed = false) }
+ }
+ }
+
+ private suspend fun loadPreview(ready: WidgetConfigUiState.Ready): WidgetPreviewData = when (ready.kind) {
+ WidgetKind.AGENDA -> {
+ val settings = prefs.widgetSettings.first()
+ WidgetPreviewData.Agenda(context.loadAgendaWidgetData(settings, requireNotNull(ready.agenda)))
+ }
+ WidgetKind.MONTH -> {
+ val index = monthIndexOf(today(systemZone()))
+ WidgetPreviewData.Month(context.loadThemedMonthSource(monthWindowCenter(index)).content, index)
+ }
+ }
+
+ /** Writes the draft into the widget and redraws it, then reports how that went on [events]. */
+ fun save() {
+ val ready = _state.value as? WidgetConfigUiState.Ready ?: return
+ if (ready.saving) return
+ edit { it.copy(saving = true) }
+ viewModelScope.launch {
+ try {
+ val glanceId = GlanceAppWidgetManager(context).getGlanceIdBy(appWidgetId)
+ ready.kind.widget().updateWithRefresh(context, glanceId) { state ->
+ state.putAppearance(ready.appearance)
+ ready.agenda?.let { state.putAgendaOptions(it) }
+ }
+ _events.send(WidgetConfigEvent.SAVED)
+ } catch (e: CancellationException) {
+ throw e
+ } catch (_: IllegalArgumentException) {
+ _state.value = WidgetConfigUiState.Missing
+ } catch (_: Exception) {
+ edit { it.copy(saving = false) }
+ _events.send(WidgetConfigEvent.SAVE_FAILED)
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/widget/month/MonthGrid.kt b/app/src/main/java/de/jeanlucmakiola/calendula/widget/month/MonthGrid.kt
index 53a8815..01d70b3 100644
--- a/app/src/main/java/de/jeanlucmakiola/calendula/widget/month/MonthGrid.kt
+++ b/app/src/main/java/de/jeanlucmakiola/calendula/widget/month/MonthGrid.kt
@@ -31,11 +31,12 @@ internal class ViewIds(sdkInt: Int = Build.VERSION.SDK_INT) {
val clickableSized = sized + 2
/**
- * `MonthWidgetBody`'s views outside the week rows: its Column, the header
- * row with three sized clickable icon boxes, their sized icons and the
- * clickable title, the spacer, and the weekday row of sized labels.
+ * `MonthWidgetBody`'s views outside the week rows: the surface Box, its
+ * Column, the header row with three sized clickable icon boxes, their sized
+ * icons and the clickable title, the spacer, the grid's Column and the
+ * weekday row of sized labels.
*/
- val fixed = plain + plain + 3 * (clickableSized + sized) + clickablePlain + sized + plain + 7 * sized
+ val fixed = plain + plain + plain + 3 * (clickableSized + sized) + clickablePlain + sized + plain + plain + 7 * sized
}
/** A cell [cols] columns wide holding [item], or a merged gap where [item] is null. */
diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/widget/month/MonthWidget.kt b/app/src/main/java/de/jeanlucmakiola/calendula/widget/month/MonthWidget.kt
index 332726e..c0443ad 100644
--- a/app/src/main/java/de/jeanlucmakiola/calendula/widget/month/MonthWidget.kt
+++ b/app/src/main/java/de/jeanlucmakiola/calendula/widget/month/MonthWidget.kt
@@ -26,7 +26,6 @@ import androidx.glance.action.actionParametersOf
import androidx.glance.action.clickable
import androidx.glance.appwidget.GlanceAppWidget
import androidx.glance.appwidget.SizeMode
-import androidx.glance.color.ColorProvider
import androidx.glance.appwidget.action.ActionCallback
import androidx.glance.appwidget.action.actionRunCallback
import androidx.glance.appwidget.action.actionStartActivity
@@ -63,9 +62,14 @@ import de.jeanlucmakiola.calendula.ui.month.MonthWeek
import de.jeanlucmakiola.calendula.ui.month.layoutMonthWeeks
import de.jeanlucmakiola.calendula.ui.common.eventFill
import de.jeanlucmakiola.calendula.ui.common.eventInk
-import de.jeanlucmakiola.calendula.widget.CalendulaGlanceTheme
+import de.jeanlucmakiola.calendula.widget.CalendulaWidgetRoot
+import de.jeanlucmakiola.calendula.widget.WidgetAppearance
+import de.jeanlucmakiola.calendula.widget.widgetAppearance
+import de.jeanlucmakiola.floret.glance.themedColor
+import de.jeanlucmakiola.floret.glance.wallpaperBacking
+import de.jeanlucmakiola.floret.prefs.ThemeMode
import de.jeanlucmakiola.calendula.widget.MonthWidgetSource
-import de.jeanlucmakiola.calendula.widget.loadMonthWidgetSource
+import de.jeanlucmakiola.calendula.widget.loadCachedMonthSource
import de.jeanlucmakiola.calendula.widget.monthWindowCenter
import de.jeanlucmakiola.floret.glance.WidgetData
import de.jeanlucmakiola.floret.glance.currentRefreshStamp
@@ -134,16 +138,17 @@ class MonthWidget : GlanceAppWidget() {
val stamp = refreshStamp(context, id)
val initialCenter = monthWindowCenter(shown)
// Shared by every size Exact composes, so each loads nothing of its own.
- val widgetData = WidgetData(context.loadMonthWidgetSource(id, initialCenter, stamp), initialCenter, stamp)
+ val widgetData = WidgetData(context.loadCachedMonthSource(id, initialCenter, stamp), initialCenter, stamp)
provideContent {
val index = currentState(MONTH_INDEX_KEY) ?: currentMonthIndex(systemZone())
// Reloads when paging leaves the loaded window, and on every redraw,
// which a running session would otherwise never see (#420).
val center = monthWindowCenter(index)
val currentStamp = currentRefreshStamp()
- val source = widgetData.current(center, currentStamp) {
- context.loadMonthWidgetSource(id, center, currentStamp)
+ val loaded = widgetData.current(center, currentStamp) {
+ context.loadCachedMonthSource(id, center, currentStamp)
}
+ val source = loaded.content
// A jump out of the loaded window keeps the last month it covers
// until the reload lands, rather than drawing an empty grid.
var lastShown by remember { mutableIntStateOf(index) }
@@ -153,13 +158,24 @@ class MonthWidget : GlanceAppWidget() {
else -> index.coerceIn(source.months)
}
SideEffect { lastShown = shown }
- CalendulaGlanceTheme {
- MonthWidgetBody(source = source, index = shown)
- }
+ MonthWidgetContent(
+ source = source,
+ index = shown,
+ appearance = currentState().widgetAppearance,
+ appTheme = loaded.appTheme,
+ )
}
}
}
+/** The whole month widget showing month [index], shared with its config screen's preview (#292). */
+@Composable
+internal fun MonthWidgetContent(source: MonthWidgetSource, index: Int, appearance: WidgetAppearance, appTheme: ThemeMode) {
+ CalendulaWidgetRoot(appearance, appTheme) {
+ MonthWidgetBody(source = source, index = index)
+ }
+}
+
/**
* Step the displayed month by the `delta` action parameter (±1).
*
@@ -209,7 +225,6 @@ private fun MonthWidgetBody(source: MonthWidgetSource, index: Int) {
Column(
modifier = GlanceModifier
.fillMaxSize()
- .background(GlanceTheme.colors.surface)
.padding(horizontal = GRID_HPADDING, vertical = 6.dp),
) {
when (source) {
@@ -226,16 +241,19 @@ private fun MonthWidgetBody(source: MonthWidgetSource, index: Int) {
MonthHeader(label = monthLabel(ym, source.today.year))
Spacer(GlanceModifier.height(2.dp))
- WeekdayHeader(weekStart = source.weekStart, colW = colW)
- layoutWeeks(weeks, source.today).forEach { layout ->
- WeekRow(
- layout = layout,
- currentMonth = ym.month,
- today = source.today,
- soften = source.soften,
- colW = colW,
- modifier = GlanceModifier.defaultWeight(),
- )
+ // One backing for the whole grid on a transparent widget (#292).
+ Column(modifier = GlanceModifier.fillMaxWidth().defaultWeight().wallpaperBacking()) {
+ WeekdayHeader(weekStart = source.weekStart, colW = colW)
+ layoutWeeks(weeks, source.today).forEach { layout ->
+ WeekRow(
+ layout = layout,
+ currentMonth = ym.month,
+ today = source.today,
+ soften = source.soften,
+ colW = colW,
+ modifier = GlanceModifier.defaultWeight(),
+ )
+ }
}
}
}
@@ -246,7 +264,7 @@ private fun MonthWidgetBody(source: MonthWidgetSource, index: Int) {
private fun MonthHeader(label: String) {
val context = LocalContext.current
Row(
- modifier = GlanceModifier.fillMaxWidth(),
+ modifier = GlanceModifier.fillMaxWidth().wallpaperBacking(),
verticalAlignment = Alignment.CenterVertically,
) {
HeaderIcon(
@@ -470,14 +488,14 @@ private fun SpanBar(event: EventInstance, soften: Boolean, width: Dp) {
modifier = GlanceModifier
.fillMaxSize()
.cornerRadius(4.dp)
- .background(ColorProvider(day = dayFill, night = nightFill)),
+ .background(themedColor(day = dayFill, night = nightFill)),
contentAlignment = Alignment.CenterStart,
) {
Text(
text = event.title.ifBlank { context.getString(R.string.event_untitled) },
maxLines = 1,
style = TextStyle(
- color = ColorProvider(day = eventInk(dayFill), night = eventInk(nightFill)),
+ color = themedColor(day = eventInk(dayFill), night = eventInk(nightFill)),
fontSize = 9.sp,
textDecoration = glanceDeclinedDecoration(event.isDeclined),
),
@@ -520,6 +538,7 @@ private fun PermissionMessage() {
fontSize = 14.sp,
textAlign = TextAlign.Center,
),
+ modifier = GlanceModifier.wallpaperBacking().padding(horizontal = 12.dp, vertical = 8.dp),
)
}
}
diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/widget/month/MonthWidgetReceiver.kt b/app/src/main/java/de/jeanlucmakiola/calendula/widget/month/MonthWidgetReceiver.kt
index 9686a29..7df07dc 100644
--- a/app/src/main/java/de/jeanlucmakiola/calendula/widget/month/MonthWidgetReceiver.kt
+++ b/app/src/main/java/de/jeanlucmakiola/calendula/widget/month/MonthWidgetReceiver.kt
@@ -7,6 +7,7 @@ import androidx.glance.appwidget.GlanceAppWidget
import androidx.glance.appwidget.GlanceAppWidgetReceiver
import de.jeanlucmakiola.calendula.widget.syncWidgetRollover
import de.jeanlucmakiola.floret.glance.reloadOnUpdate
+import de.jeanlucmakiola.floret.glance.restoreState
/**
* Host-facing receiver for the month widget. Declared in the manifest with the
@@ -30,6 +31,11 @@ class MonthWidgetReceiver : GlanceAppWidgetReceiver() {
syncWidgetRollover(context)
}
+ /** A backup restore or device transfer renumbered the widgets: keep each one's settings (#292). */
+ override fun onRestored(context: Context, oldWidgetIds: IntArray, newWidgetIds: IntArray) {
+ restoreState(context, oldWidgetIds, newWidgetIds)
+ }
+
/**
* The `updatePeriodMillis` backstop is the one wake-up the *system* still
* owns, so it doubles as the alarm's self-heal: anything that drops a
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index 0ef6379..b778d26 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -441,6 +441,21 @@
Previous month
Next month
Today
+ Background
+ Solid
+ Tinted
+ Transparent
+ Follow app
+ Content
+ Time range
+ Text size
+ Couldn\'t read this widget\'s settings.
+ Preview unavailable
+ Couldn\'t save the widget. Try again.
+
+ %1$s %2$d
+
+ %1$s · %2$s
Calendars
@@ -554,7 +569,7 @@
Event text %1$d%%
Always show today
- Keep today at the top of the agenda and its widget, even once nothing is left today.
+ Keep today at the top of the agenda, even once nothing is left today. Each widget has its own setting.
Range bar
Show a bar at the top of the agenda naming the dates shown, with a button to switch the range for the session
Today
@@ -645,12 +660,15 @@
Default fields and behaviour
Reminders and delivery
Contact birthdays & anniversaries
- Agenda widget, Quick Settings tile
+ Your widgets, Quick Settings tile
Export, import, automatic backup
Widgets & tiles
- Settings for the home-screen widgets and the Quick Settings tile. Add a widget by long-pressing your home screen.
+ Each widget has its own look and options. Tap one below, or long-press it on your home screen and choose Edit. Add a widget by long-pressing your home screen.
+ Each widget has its own look and options. Tap one below to change it. Add a widget by long-pressing your home screen.
+ On your home screen
+ No Calendula widgets on your home screen yet.
Backup & restore
diff --git a/app/src/main/res/values/themes.xml b/app/src/main/res/values/themes.xml
index fed97fa..a161487 100644
--- a/app/src/main/res/values/themes.xml
+++ b/app/src/main/res/values/themes.xml
@@ -7,4 +7,10 @@
- @color/window_background
- true
+
+
+
diff --git a/app/src/main/res/xml/appwidget_info_agenda.xml b/app/src/main/res/xml/appwidget_info_agenda.xml
index 12abbe3..b8c707c 100644
--- a/app/src/main/res/xml/appwidget_info_agenda.xml
+++ b/app/src/main/res/xml/appwidget_info_agenda.xml
@@ -8,6 +8,8 @@
android:minResizeHeight="110dp"
android:resizeMode="horizontal|vertical"
android:widgetCategory="home_screen"
+ android:configure="de.jeanlucmakiola.calendula.widget.config.WidgetConfigActivity"
+ android:widgetFeatures="reconfigurable"
android:description="@string/widget_agenda_label"
android:updatePeriodMillis="1800000"
android:previewLayout="@layout/widget_preview_agenda"
diff --git a/app/src/main/res/xml/appwidget_info_month.xml b/app/src/main/res/xml/appwidget_info_month.xml
index cfa7acb..075aa51 100644
--- a/app/src/main/res/xml/appwidget_info_month.xml
+++ b/app/src/main/res/xml/appwidget_info_month.xml
@@ -8,6 +8,8 @@
android:minResizeHeight="150dp"
android:resizeMode="horizontal|vertical"
android:widgetCategory="home_screen"
+ android:configure="de.jeanlucmakiola.calendula.widget.config.WidgetConfigActivity"
+ android:widgetFeatures="reconfigurable"
android:description="@string/widget_month_label"
android:updatePeriodMillis="1800000"
android:previewLayout="@layout/widget_preview_month"
diff --git a/app/src/test/java/de/jeanlucmakiola/calendula/data/prefs/SettingsPrefsTest.kt b/app/src/test/java/de/jeanlucmakiola/calendula/data/prefs/SettingsPrefsTest.kt
index 44101d1..74a425b 100644
--- a/app/src/test/java/de/jeanlucmakiola/calendula/data/prefs/SettingsPrefsTest.kt
+++ b/app/src/test/java/de/jeanlucmakiola/calendula/data/prefs/SettingsPrefsTest.kt
@@ -165,15 +165,12 @@ class SettingsPrefsTest {
}
@Test
- fun `agenda ranges default to month and round-trip independently`(@TempDir tempDir: Path) = runTest {
+ fun `agenda screen range defaults to month and round-trips`(@TempDir tempDir: Path) = runTest {
val prefs = testSettings(tempDir)
assertThat(prefs.agendaScreenRange.first()).isEqualTo(AgendaRange.Month)
- assertThat(prefs.agendaWidgetRange.first()).isEqualTo(AgendaRange.Month)
prefs.setAgendaScreenRange(AgendaRange.Custom(14))
- prefs.setAgendaWidgetRange(AgendaRange.Week)
assertThat(prefs.agendaScreenRange.first()).isEqualTo(AgendaRange.Custom(14))
- assertThat(prefs.agendaWidgetRange.first()).isEqualTo(AgendaRange.Week)
}
@Test
diff --git a/floret-kit b/floret-kit
index 96509d0..6dc7bc5 160000
--- a/floret-kit
+++ b/floret-kit
@@ -1 +1 @@
-Subproject commit 96509d0f9d6bd8b8cea65fbe06b93d2ae6088b55
+Subproject commit 6dc7bc50c5509d50a2e69cf34430640c90dd33bd