Per-widget config screen: background, theme and agenda options (#292)
This commit is contained in:
@@ -318,6 +318,16 @@
|
||||
android:exported="false" />
|
||||
|
||||
<!-- Home-screen widgets (Glance). Exported: the launcher/host binds them. -->
|
||||
<activity
|
||||
android:name=".widget.config.WidgetConfigActivity"
|
||||
android:exported="true"
|
||||
android:excludeFromRecents="true"
|
||||
android:taskAffinity=""
|
||||
android:theme="@style/Theme.Calendula.WidgetConfig">
|
||||
<intent-filter>
|
||||
<action android:name="android.appwidget.action.APPWIDGET_CONFIGURE" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
<receiver
|
||||
android:name=".widget.agenda.AgendaWidgetReceiver"
|
||||
android:label="@string/widget_agenda_label"
|
||||
|
||||
@@ -154,8 +154,6 @@ class SettingsPrefs @Inject constructor(
|
||||
val dragToReschedule: Flow<Boolean> = prefs.flow(SettingsKeys.dragToReschedule)
|
||||
val tapOpensEditor: Flow<Boolean> = prefs.flow(SettingsKeys.tapOpensEditor)
|
||||
val agendaScreenRange: Flow<AgendaRange> = prefs.flow(SettingsKeys.agendaScreenRange)
|
||||
val agendaWidgetRange: Flow<AgendaRange> = prefs.flow(SettingsKeys.agendaWidgetRange)
|
||||
val widgetSize: Flow<WidgetSize> = prefs.flow(SettingsKeys.widgetSize)
|
||||
val agendaShowRangeBar: Flow<Boolean> = prefs.flow(SettingsKeys.agendaShowRangeBar)
|
||||
val agendaShowToday: Flow<Boolean> = prefs.flow(SettingsKeys.agendaShowToday)
|
||||
val defaultView: Flow<CalendarView> = 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<AgendaRange> = 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<AgendaRange> = 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<WidgetSize> = enumPref("widget_size", WidgetSize.SMALL)
|
||||
|
||||
@@ -511,8 +510,8 @@ object SettingsKeys {
|
||||
val agendaShowRangeBar: Pref<Boolean> = 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<Boolean> = booleanPref("agenda_show_today", true)
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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). */
|
||||
|
||||
@@ -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) }
|
||||
|
||||
@@ -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<List<PlacedWidget>?>(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<WidgetKind, Int>()
|
||||
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
|
||||
},
|
||||
)
|
||||
|
||||
@@ -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)
|
||||
@@ -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<Boolean, WeekStartPref> 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<out T>(val content: T, val appTheme: ThemeMode)
|
||||
|
||||
internal val SettingsPrefs.widgetSettings: Flow<WidgetSettings>
|
||||
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<AgendaWidgetData> {
|
||||
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<MonthWidgetSource.Ready>)
|
||||
|
||||
private val entries = ConcurrentHashMap<GlanceId, Entry>()
|
||||
|
||||
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<MonthWidgetSource>? =
|
||||
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<MonthWidgetSource>) {
|
||||
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<MonthWidgetSource> =
|
||||
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<MonthWidgetSource> {
|
||||
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)
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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<Preferences>().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<AgendaRow>): List<Position> = 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),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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].
|
||||
|
||||
@@ -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<PlacedWidget> {
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
},
|
||||
)
|
||||
@@ -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>(WidgetConfigUiState.Loading)
|
||||
val state: StateFlow<WidgetConfigUiState> = _state.asStateFlow()
|
||||
|
||||
private val _events = Channel<WidgetConfigEvent>(Channel.BUFFERED)
|
||||
val events: Flow<WidgetConfigEvent> = _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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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. */
|
||||
|
||||
@@ -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<Preferences>().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),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -441,6 +441,21 @@
|
||||
<string name="widget_prev_month">Previous month</string>
|
||||
<string name="widget_next_month">Next month</string>
|
||||
<string name="widget_today">Today</string>
|
||||
<string name="widget_config_background">Background</string>
|
||||
<string name="widget_config_background_solid">Solid</string>
|
||||
<string name="widget_config_background_tinted">Tinted</string>
|
||||
<string name="widget_config_background_transparent">Transparent</string>
|
||||
<string name="widget_config_theme_follow_app">Follow app</string>
|
||||
<string name="widget_config_content">Content</string>
|
||||
<string name="widget_config_range">Time range</string>
|
||||
<string name="widget_config_text_size">Text size</string>
|
||||
<string name="widget_config_load_failed">Couldn\'t read this widget\'s settings.</string>
|
||||
<string name="widget_config_preview_failed">Preview unavailable</string>
|
||||
<string name="widget_config_save_failed">Couldn\'t save the widget. Try again.</string>
|
||||
<!-- A widget's name and its number, when there are several of the same kind: "Calendula agenda 2" -->
|
||||
<string name="widget_numbered">%1$s %2$d</string>
|
||||
<!-- A placed widget's background and theme: "Tinted · Follow app" -->
|
||||
<string name="widget_appearance_summary">%1$s · %2$s</string>
|
||||
|
||||
<!-- Calendar filter (M3) -->
|
||||
<string name="filter_title">Calendars</string>
|
||||
@@ -554,7 +569,7 @@
|
||||
<!-- %1$d is the event text size as a percentage of the smallest step, e.g. 130. -->
|
||||
<string name="settings_widget_size_summary">Event text %1$d%%</string>
|
||||
<string name="settings_agenda_show_today">Always show today</string>
|
||||
<string name="settings_agenda_show_today_hint">Keep today at the top of the agenda and its widget, even once nothing is left today.</string>
|
||||
<string name="settings_agenda_show_today_hint">Keep today at the top of the agenda, even once nothing is left today. Each widget has its own setting.</string>
|
||||
<string name="settings_agenda_range_bar">Range bar</string>
|
||||
<string name="settings_agenda_range_bar_hint">Show a bar at the top of the agenda naming the dates shown, with a button to switch the range for the session</string>
|
||||
<string name="agenda_range_day">Today</string>
|
||||
@@ -645,12 +660,15 @@
|
||||
<string name="settings_event_form_subtitle">Default fields and behaviour</string>
|
||||
<string name="settings_notifications_subtitle">Reminders and delivery</string>
|
||||
<string name="settings_special_dates_subtitle">Contact birthdays & anniversaries</string>
|
||||
<string name="settings_widgets_subtitle">Agenda widget, Quick Settings tile</string>
|
||||
<string name="settings_widgets_subtitle">Your widgets, Quick Settings tile</string>
|
||||
<string name="settings_backup_subtitle">Export, import, automatic backup</string>
|
||||
|
||||
<!-- Widgets & tiles (#69) -->
|
||||
<string name="settings_section_widgets">Widgets & tiles</string>
|
||||
<string name="settings_widgets_hint">Settings for the home-screen widgets and the Quick Settings tile. Add a widget by long-pressing your home screen.</string>
|
||||
<string name="settings_widgets_hint">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.</string>
|
||||
<string name="settings_widgets_hint_legacy">Each widget has its own look and options. Tap one below to change it. Add a widget by long-pressing your home screen.</string>
|
||||
<string name="settings_widgets_placed">On your home screen</string>
|
||||
<string name="settings_widgets_none">No Calendula widgets on your home screen yet.</string>
|
||||
|
||||
<!-- Backup & restore (#69) — the rows themselves reuse the calendars_* strings -->
|
||||
<string name="settings_section_backup">Backup & restore</string>
|
||||
|
||||
@@ -7,4 +7,10 @@
|
||||
<item name="android:windowBackground">@color/window_background</item>
|
||||
<item name="android:windowLightStatusBar">true</item>
|
||||
</style>
|
||||
|
||||
<!-- Widget config: drawn over the wallpaper, so the preview shows the real home screen. -->
|
||||
<style name="Theme.Calendula.WidgetConfig">
|
||||
<item name="android:windowBackground">@android:color/transparent</item>
|
||||
<item name="android:windowShowWallpaper">true</item>
|
||||
</style>
|
||||
</resources>
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
+1
-1
Submodule floret-kit updated: 96509d0f9d...6dc7bc50c5
Reference in New Issue
Block a user