From 35f5dc16666ebb19de7b1f2d23d09a922ff7eeff Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Wed, 21 Oct 2026 17:36:30 +0200 Subject: [PATCH] Fix widget onUpdate crash, skip provider echoes, per-widget redraws (#420, #229) --- .../jeanlucmakiola/calendula/CalendulaApp.kt | 10 +- .../calendula/widget/WidgetData.kt | 95 +++++++++++++++---- .../calendula/widget/WidgetRedraw.kt | 55 ++++++++--- .../calendula/widget/WidgetUpdateReceiver.kt | 5 +- .../calendula/widget/agenda/AgendaWidget.kt | 16 ++-- .../widget/agenda/AgendaWidgetReceiver.kt | 4 +- .../calendula/widget/month/MonthWidget.kt | 45 +++++---- .../widget/month/MonthWidgetReceiver.kt | 4 +- .../calendula/widget/MonthWindowCenterTest.kt | 4 +- floret-kit | 2 +- 10 files changed, 172 insertions(+), 68 deletions(-) diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/CalendulaApp.kt b/app/src/main/java/de/jeanlucmakiola/calendula/CalendulaApp.kt index 64e321a..ab7e1d7 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/CalendulaApp.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/CalendulaApp.kt @@ -44,24 +44,24 @@ class CalendulaApp : Application() { chooseIssueUrl = getString(R.string.report_issue_choose_url), ), ) + // Before anything that writes, so the widgets see every change (#420). + widgetEntryPoint().widgetRefreshCoordinator().start() reconcileAutoBackup() reconcileSpecialDates() reconcileCalendarVisibility() startReminderDelivery() - startWidgets() + reconcileWidgetRollover() } /** * Re-arm the widgets' midnight rollover from whatever is actually placed - * (#228), and start redrawing them on the app's own changes (#420). The - * re-arm is idempotent and covers what no broadcast reaches — an alarm + * (#228). Idempotent, and it covers what no broadcast reaches — an alarm * dropped by a force-stop is armed again the next time the app is opened. * Off the main thread: a handful of binder calls on every process start. */ - private fun startWidgets() { + private fun reconcileWidgetRollover() { CoroutineScope(SupervisorJob() + Dispatchers.Default).launch { syncWidgetRollover(this@CalendulaApp) - widgetEntryPoint().widgetRefreshCoordinator().start() } } diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/widget/WidgetData.kt b/app/src/main/java/de/jeanlucmakiola/calendula/widget/WidgetData.kt index 6dfe5fd..5b5f4a9 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/widget/WidgetData.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/widget/WidgetData.kt @@ -4,15 +4,25 @@ import android.Manifest import android.content.Context import android.content.pm.PackageManager import androidx.core.content.ContextCompat +import androidx.glance.GlanceId import de.jeanlucmakiola.calendula.data.prefs.PastEventDisplay +import de.jeanlucmakiola.calendula.data.prefs.SettingsKeys +import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs +import de.jeanlucmakiola.calendula.data.prefs.TimeFormatPref +import de.jeanlucmakiola.calendula.data.prefs.WeekStartPref import de.jeanlucmakiola.calendula.data.prefs.is24Hour import de.jeanlucmakiola.calendula.data.prefs.resolveFirstDay import de.jeanlucmakiola.calendula.domain.EventInstance import de.jeanlucmakiola.calendula.ui.agenda.AgendaDay +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.get +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.map import kotlinx.datetime.DateTimeUnit import kotlinx.datetime.DayOfWeek import kotlinx.datetime.LocalDate @@ -24,6 +34,7 @@ import kotlinx.datetime.plus import kotlinx.datetime.toInstant import kotlinx.datetime.toLocalDateTime import java.util.Locale +import java.util.concurrent.ConcurrentHashMap import kotlin.time.Clock import kotlin.time.Instant @@ -33,7 +44,34 @@ import kotlin.time.Instant * the whole window, and on slow devices a wide one held the widget on "loading" * (#363). */ -private const val MONTH_WIDGET_PAD_MONTHS = 3 +internal const val MONTH_WIDGET_PAD_MONTHS = 3 + +/** Every setting the widgets draw with: what the loads read, and what [WidgetRefreshCoordinator] watches. */ +internal data class WidgetSettings( + val soften: Boolean, + val weekStart: WeekStartPref, + val timeFormat: TimeFormatPref, + val pastDisplay: PastEventDisplay, + val agendaRange: AgendaRange, + val widgetSize: WidgetSize, + val showToday: Boolean, +) { + /** The part the month widget draws with; the rest is agenda-only. */ + val month: Pair get() = soften to weekStart +} + +internal val SettingsPrefs.widgetSettings: Flow + get() = data.map { + WidgetSettings( + soften = it[SettingsKeys.softenCalendarColors], + weekStart = it[SettingsKeys.weekStart], + timeFormat = it[SettingsKeys.timeFormat], + pastDisplay = it[SettingsKeys.pastEventDisplay], + agendaRange = it[SettingsKeys.agendaWidgetRange], + widgetSize = it[SettingsKeys.widgetSize], + showToday = it[SettingsKeys.agendaShowToday], + ) + }.distinctUntilChanged() internal fun systemZone(): TimeZone = TimeZone.currentSystemDefault() @@ -78,6 +116,8 @@ sealed interface MonthWidgetSource { val weekStart: DayOfWeek, val soften: Boolean, val instances: List, + /** Month indices (`year * 12 + monthOrdinal`) whose grids [instances] covers. */ + val months: IntRange, ) : MonthWidgetSource } @@ -91,22 +131,20 @@ internal suspend fun Context.loadAgendaWidgetData(): AgendaWidgetData { val zone = systemZone() val anchor = today(zone) val ep = widgetEntryPoint() - val prefs = ep.settingsPrefs() - val range = prefs.agendaWidgetRange.first() - val weekStart = prefs.weekStart.first().resolveFirstDay(Locale.getDefault()) - val dayCount = range.dayCount(anchor, weekStart) + val settings = ep.settingsPrefs().widgetSettings.first() + val weekStart = settings.weekStart.resolveFirstDay(Locale.getDefault()) + val dayCount = settings.agendaRange.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() return AgendaWidgetData.Ready( today = anchor, days = groupAgendaDays(anchor, windowEnd, instances, zone), - is24Hour = prefs.timeFormat.first() - .is24Hour(android.text.format.DateFormat.is24HourFormat(this)), - soften = prefs.softenCalendarColors.first(), - pastDisplay = prefs.pastEventDisplay.first(), - showToday = prefs.agendaShowToday.first(), - widgetSize = prefs.widgetSize.first(), + is24Hour = settings.timeFormat.is24Hour(android.text.format.DateFormat.is24HourFormat(this)), + soften = settings.soften, + pastDisplay = settings.pastDisplay, + showToday = settings.showToday, + widgetSize = settings.widgetSize, now = Clock.System.now(), ) } @@ -122,13 +160,37 @@ private fun firstDayOfMonthIndex(index: Int): LocalDate = LocalDate(index / 12, */ internal fun monthWindowCenter(index: Int): Int = Math.floorDiv(index, 3) * 3 + 1 +/** + * The last window each month widget loaded, under its refresh stamp, so a new + * session for the same widget (a tap after the last one timed out) draws + * without a query. Every redraw moves the stamp, so an entry is never served + * stale. + */ +private object MonthSourceCache { + private class Entry(val center: Int, val stamp: Long, val source: MonthWidgetSource.Ready) + + private val entries = ConcurrentHashMap() + + fun get(id: GlanceId, center: Int, stamp: Long): MonthWidgetSource.Ready? = + entries[id]?.takeIf { it.center == center && it.stamp == stamp && it.source.today == today(systemZone()) } + ?.source + + fun put(id: GlanceId, center: Int, stamp: Long, source: MonthWidgetSource) { + if (source is MonthWidgetSource.Ready) entries[id] = Entry(center, stamp, source) + } +} + +/** [loadMonthWidgetSource] for widget [id], served from [MonthSourceCache] while its [stamp] holds. */ +internal suspend fun Context.loadMonthWidgetSource(id: GlanceId, centerIndex: Int, stamp: Long): MonthWidgetSource = + MonthSourceCache.get(id, centerIndex, stamp) + ?: loadMonthWidgetSource(centerIndex).also { MonthSourceCache.put(id, centerIndex, stamp, it) } + /** Read backing the month widget's grid for the window around [centerIndex]. */ -internal suspend fun Context.loadMonthWidgetSource(centerIndex: Int): MonthWidgetSource { +private suspend fun Context.loadMonthWidgetSource(centerIndex: Int): MonthWidgetSource { if (!hasCalendarPermission()) return MonthWidgetSource.NeedsPermission val zone = systemZone() val ep = widgetEntryPoint() - val prefs = ep.settingsPrefs() - val weekStart = prefs.weekStart.first().resolveFirstDay(Locale.getDefault()) + 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) @@ -140,8 +202,9 @@ internal suspend fun Context.loadMonthWidgetSource(centerIndex: Int): MonthWidge .toInstant(zone) return MonthWidgetSource.Ready( today = today(zone), - weekStart = weekStart, - soften = prefs.softenCalendarColors.first(), + weekStart = settings.weekStart.resolveFirstDay(Locale.getDefault()), + soften = settings.soften, instances = ep.calendarRepository().instances(from..to).first(), + months = months, ) } diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/widget/WidgetRedraw.kt b/app/src/main/java/de/jeanlucmakiola/calendula/widget/WidgetRedraw.kt index a62c72a..222228d 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/widget/WidgetRedraw.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/widget/WidgetRedraw.kt @@ -3,6 +3,7 @@ package de.jeanlucmakiola.calendula.widget import android.app.PendingIntent import android.content.Context import android.content.Intent +import android.os.SystemClock import de.jeanlucmakiola.calendula.data.calendar.CalendarRepository import de.jeanlucmakiola.calendula.data.di.ApplicationScope import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs @@ -15,12 +16,15 @@ import de.jeanlucmakiola.floret.glance.WidgetRollover import de.jeanlucmakiola.floret.glance.updatePlaced import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.CoroutineStart import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.drop import kotlinx.coroutines.flow.map -import kotlinx.coroutines.flow.merge import kotlinx.coroutines.launch +import java.util.concurrent.atomic.AtomicBoolean +import java.util.concurrent.atomic.AtomicLong import javax.inject.Inject import javax.inject.Singleton @@ -52,15 +56,32 @@ internal fun syncWidgetRollover(context: Context) = * refresh stamp moves, and both widgets key their data load on it (#420). */ internal suspend fun redrawWidgets(context: Context) { + redrawAgendaWidgets(context) + redrawMonthWidgets(context) +} + +private suspend fun redrawAgendaWidgets(context: Context) = AgendaWidget().updatePlaced(context, AgendaWidgetReceiver::class.java, bumpRefreshStamp = true) + +private suspend fun redrawMonthWidgets(context: Context) = MonthWidget().updatePlaced(context, MonthWidgetReceiver::class.java, bumpRefreshStamp = true) + +/** When the app last saw a calendar change of its own, for skipping the `PROVIDER_CHANGED` that echoes it. */ +private val lastLocalChange = AtomicLong(0L) + +private const val PROVIDER_ECHO_WINDOW_MILLIS = 5_000L + +internal fun isProviderEcho(): Boolean { + val last = lastLocalChange.get() + return last != 0L && SystemClock.elapsedRealtime() - last < PROVIDER_ECHO_WINDOW_MILLIS } /** * 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. Started once from `CalendulaApp`. + * the app are [WidgetUpdateReceiver]'s. A setting only the agenda widget draws + * with leaves the month widget alone. Started once from `CalendulaApp`. */ @Singleton class WidgetRefreshCoordinator @Inject constructor( @@ -69,19 +90,25 @@ class WidgetRefreshCoordinator @Inject constructor( private val prefs: SettingsPrefs, @ApplicationScope private val scope: CoroutineScope, ) { + private val started = AtomicBoolean(false) + + /** Subscribes before returning, so a change made right after is never taken for the starting value. */ fun start() { - val refresher = WidgetRefresher(scope) { redrawWidgets(context) } - val settings = listOf( - prefs.softenCalendarColors, - prefs.weekStart, - prefs.timeFormat, - prefs.pastEventDisplay, - prefs.agendaWidgetRange, - prefs.widgetSize, - prefs.agendaShowToday, - ).map { flow -> flow.distinctUntilChanged().drop(1).map {} } - scope.launch(Dispatchers.IO) { - merge(repository.localChanges, *settings.toTypedArray()).collect { refresher.request() } + if (!started.compareAndSet(false, true)) return + val agenda = WidgetRefresher(scope) { redrawAgendaWidgets(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() } + } + + private fun watch(changes: Flow<*>, onChange: () -> Unit) { + scope.launch(Dispatchers.IO, start = CoroutineStart.UNDISPATCHED) { + changes.collect { onChange() } } } } diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/widget/WidgetUpdateReceiver.kt b/app/src/main/java/de/jeanlucmakiola/calendula/widget/WidgetUpdateReceiver.kt index 0182171..45ddeb1 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/widget/WidgetUpdateReceiver.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/widget/WidgetUpdateReceiver.kt @@ -12,7 +12,8 @@ import kotlin.time.Duration.Companion.minutes * the app; the app's own writes go through [WidgetRefreshCoordinator]. A data * change also re-arms, at most every 15 minutes: the inexact alarm can be * dropped in a restricted standby bucket, and a calendar change is the one - * signal that keeps arriving. + * signal that keeps arriving. A `PROVIDER_CHANGED` right after one of the + * app's own changes only echoes it, so it doesn't redraw a second time. */ class WidgetUpdateReceiver : FloretWidgetUpdateReceiver() { override val rolloverAction = ACTION_WIDGET_ROLLOVER @@ -22,5 +23,7 @@ class WidgetUpdateReceiver : FloretWidgetUpdateReceiver() { override fun rolloverPendingIntent(context: Context): PendingIntent = widgetRolloverPendingIntent(context) + override fun skipRedraw(action: String) = action == Intent.ACTION_PROVIDER_CHANGED && isProviderEcho() + override suspend fun redraw(context: Context) = redrawWidgets(context) } diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/widget/agenda/AgendaWidget.kt b/app/src/main/java/de/jeanlucmakiola/calendula/widget/agenda/AgendaWidget.kt index 7fc4ace..f027750 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/widget/agenda/AgendaWidget.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/widget/agenda/AgendaWidget.kt @@ -3,7 +3,6 @@ package de.jeanlucmakiola.calendula.widget.agenda import de.jeanlucmakiola.calendula.domain.isDeclined import de.jeanlucmakiola.calendula.widget.glanceDeclinedDecoration import android.content.Context -import android.content.res.Configuration import androidx.compose.runtime.Composable import androidx.compose.ui.graphics.Color import androidx.compose.ui.unit.dp @@ -17,6 +16,7 @@ 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 @@ -90,14 +90,12 @@ class AgendaWidget : GlanceAppWidget() { override suspend fun provideGlance(context: Context, id: GlanceId) { val stamp = refreshStamp(context, id) val widgetData = WidgetData(context.loadAgendaWidgetData(), stamp) - val dark = (context.resources.configuration.uiMode and - Configuration.UI_MODE_NIGHT_MASK) == Configuration.UI_MODE_NIGHT_YES 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, dark = dark) + AgendaWidgetBody(data = data) } } } @@ -128,7 +126,7 @@ private sealed interface AgendaRow { } @Composable -private fun AgendaWidgetBody(data: AgendaWidgetData, dark: Boolean) { +private fun AgendaWidgetBody(data: AgendaWidgetData) { // 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 @@ -144,7 +142,7 @@ private fun AgendaWidgetBody(data: AgendaWidgetData, dark: Boolean) { Spacer(GlanceModifier.height(4.dp)) when (data) { AgendaWidgetData.NeedsPermission -> WidgetMessage(R.string.widget_needs_permission, metrics) - is AgendaWidgetData.Ready -> AgendaRows(data = data, dark = dark, metrics = metrics) + is AgendaWidgetData.Ready -> AgendaRows(data = data, metrics = metrics) } } } @@ -152,7 +150,6 @@ private fun AgendaWidgetBody(data: AgendaWidgetData, dark: Boolean) { @Composable private fun AgendaRows( data: AgendaWidgetData.Ready, - dark: Boolean, metrics: AgendaMetrics, ) { val pastDisplay = data.pastDisplay @@ -200,7 +197,6 @@ private fun AgendaRows( is AgendaRow.Event -> EventRow( event = row.event, day = row.date, - dark = dark, soften = data.soften, is24Hour = data.is24Hour, dimmed = pastDisplay == PastEventDisplay.DIM && @@ -332,7 +328,6 @@ private fun PlaceholderRow(date: LocalDate, metrics: AgendaMetrics) { private fun EventRow( event: EventInstance, day: LocalDate, - dark: Boolean, soften: Boolean, is24Hour: Boolean, dimmed: Boolean, @@ -342,9 +337,10 @@ private fun EventRow( val title = event.title.ifBlank { context.getString(R.string.event_untitled) } // Glance has no generic alpha modifier, so dim by fading the colour stripe and // dropping both text lines to the lower-emphasis on-surface-variant tone. - val stripeColor = eventAccent(event.color, dark, soften).let { + 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 titleColor = if (dimmed) GlanceTheme.colors.onSurfaceVariant else GlanceTheme.colors.onSurface Row( modifier = GlanceModifier diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/widget/agenda/AgendaWidgetReceiver.kt b/app/src/main/java/de/jeanlucmakiola/calendula/widget/agenda/AgendaWidgetReceiver.kt index 5d24f62..2ed8638 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/widget/agenda/AgendaWidgetReceiver.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/widget/agenda/AgendaWidgetReceiver.kt @@ -1,5 +1,6 @@ package de.jeanlucmakiola.calendula.widget.agenda +import android.annotation.SuppressLint import android.appwidget.AppWidgetManager import android.content.Context import androidx.glance.appwidget.GlanceAppWidget @@ -34,12 +35,13 @@ class AgendaWidgetReceiver : GlanceAppWidgetReceiver() { * Self-heal on the system's own `updatePeriodMillis` wake-up — see * [de.jeanlucmakiola.calendula.widget.month.MonthWidgetReceiver.onUpdate]. */ + @SuppressLint("MissingSuperCall") override fun onUpdate( context: Context, appWidgetManager: AppWidgetManager, appWidgetIds: IntArray, ) { - super.onUpdate(context, appWidgetManager, appWidgetIds) + // In place of super, which only recomposes and would take the goAsync handle. reloadOnUpdate(context, appWidgetIds) syncWidgetRollover(context) } diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/widget/month/MonthWidget.kt b/app/src/main/java/de/jeanlucmakiola/calendula/widget/month/MonthWidget.kt index 8834e48..e39a984 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/widget/month/MonthWidget.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/widget/month/MonthWidget.kt @@ -3,8 +3,12 @@ package de.jeanlucmakiola.calendula.widget.month import de.jeanlucmakiola.calendula.domain.isDeclined import de.jeanlucmakiola.calendula.widget.glanceDeclinedDecoration import android.content.Context -import android.content.res.Configuration import androidx.compose.runtime.Composable +import androidx.compose.runtime.SideEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.datastore.preferences.core.Preferences @@ -22,6 +26,7 @@ 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 @@ -131,19 +136,27 @@ 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(initialCenter), initialCenter, stamp) - val dark = (context.resources.configuration.uiMode and - Configuration.UI_MODE_NIGHT_MASK) == Configuration.UI_MODE_NIGHT_YES + val widgetData = WidgetData(context.loadMonthWidgetSource(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 source = widgetData.current(center, currentRefreshStamp()) { - context.loadMonthWidgetSource(center) + val currentStamp = currentRefreshStamp() + val source = widgetData.current(center, currentStamp) { + context.loadMonthWidgetSource(id, center, currentStamp) } + // 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) } + val shown = when { + source !is MonthWidgetSource.Ready || index in source.months -> index + lastShown in source.months -> lastShown + else -> index.coerceIn(source.months) + } + SideEffect { lastShown = shown } CalendulaGlanceTheme { - MonthWidgetBody(source = source, index = index, dark = dark) + MonthWidgetBody(source = source, index = shown) } } } @@ -194,7 +207,7 @@ class ResetMonthAction : ActionCallback { } @Composable -private fun MonthWidgetBody(source: MonthWidgetSource, index: Int, dark: Boolean) { +private fun MonthWidgetBody(source: MonthWidgetSource, index: Int) { Column( modifier = GlanceModifier .fillMaxSize() @@ -221,7 +234,6 @@ private fun MonthWidgetBody(source: MonthWidgetSource, index: Int, dark: Boolean week = week, currentMonth = ym.month, today = source.today, - dark = dark, soften = source.soften, colW = colW, modifier = GlanceModifier.defaultWeight(), @@ -331,7 +343,6 @@ private fun WeekRow( week: MonthWeek, currentMonth: Month, today: LocalDate, - dark: Boolean, soften: Boolean, colW: Dp, modifier: GlanceModifier, @@ -375,7 +386,7 @@ private fun WeekRow( // its columns (colW * n) so it's connected with no seam and rounded ends. // Lanes past the last one this week fills emit nothing at all. repeat(week.usedLanes()) { lane -> - LaneRow(week = week, lane = lane, dark = dark, soften = soften, colW = colW) + LaneRow(week = week, lane = lane, soften = soften, colW = colW) Spacer(GlanceModifier.height(1.dp)) } if (hidden.any { it > 0 }) OverflowRow(hidden = hidden, colW = colW) @@ -489,7 +500,7 @@ private fun MonthWeek.laneCells(lane: Int): List { } @Composable -private fun LaneRow(week: MonthWeek, lane: Int, dark: Boolean, soften: Boolean, colW: Dp) { +private fun LaneRow(week: MonthWeek, lane: Int, soften: Boolean, colW: Dp) { Row(modifier = GlanceModifier.fillMaxWidth()) { week.laneCells(lane).forEach { cell -> if (cell.event == null) { @@ -497,7 +508,6 @@ private fun LaneRow(week: MonthWeek, lane: Int, dark: Boolean, soften: Boolean, } else { SpanBar( event = cell.event, - dark = dark, soften = soften, width = colW * cell.cols, ) @@ -508,9 +518,10 @@ private fun LaneRow(week: MonthWeek, lane: Int, dark: Boolean, soften: Boolean, /** A single connected, rounded event bar [width] wide with its clipped title. */ @Composable -private fun SpanBar(event: EventInstance, dark: Boolean, soften: Boolean, width: Dp) { +private fun SpanBar(event: EventInstance, soften: Boolean, width: Dp) { val context = LocalContext.current - val fill = eventFill(event.color, dark, soften) + val dayFill = eventFill(event.color, dark = false, soften) + val nightFill = eventFill(event.color, dark = true, soften) Box( modifier = GlanceModifier .width(width) @@ -533,14 +544,14 @@ private fun SpanBar(event: EventInstance, dark: Boolean, soften: Boolean, width: modifier = GlanceModifier .fillMaxSize() .cornerRadius(4.dp) - .background(fill), + .background(ColorProvider(day = dayFill, night = nightFill)), contentAlignment = Alignment.CenterStart, ) { Text( text = event.title.ifBlank { context.getString(R.string.event_untitled) }, maxLines = 1, style = TextStyle( - color = ColorProvider(eventInk(fill)), + color = ColorProvider(day = eventInk(dayFill), night = eventInk(nightFill)), fontSize = 9.sp, textDecoration = glanceDeclinedDecoration(event.isDeclined), ), diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/widget/month/MonthWidgetReceiver.kt b/app/src/main/java/de/jeanlucmakiola/calendula/widget/month/MonthWidgetReceiver.kt index b1c42c1..9686a29 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/widget/month/MonthWidgetReceiver.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/widget/month/MonthWidgetReceiver.kt @@ -1,5 +1,6 @@ package de.jeanlucmakiola.calendula.widget.month +import android.annotation.SuppressLint import android.appwidget.AppWidgetManager import android.content.Context import androidx.glance.appwidget.GlanceAppWidget @@ -36,12 +37,13 @@ class MonthWidgetReceiver : GlanceAppWidgetReceiver() { * freeze) is repaired here rather than on the next app open. Re-arming * closer to midnight also narrows the inexact delivery window. */ + @SuppressLint("MissingSuperCall") override fun onUpdate( context: Context, appWidgetManager: AppWidgetManager, appWidgetIds: IntArray, ) { - super.onUpdate(context, appWidgetManager, appWidgetIds) + // In place of super, which only recomposes and would take the goAsync handle. reloadOnUpdate(context, appWidgetIds) syncWidgetRollover(context) } diff --git a/app/src/test/java/de/jeanlucmakiola/calendula/widget/MonthWindowCenterTest.kt b/app/src/test/java/de/jeanlucmakiola/calendula/widget/MonthWindowCenterTest.kt index edcad5c..7dfca3f 100644 --- a/app/src/test/java/de/jeanlucmakiola/calendula/widget/MonthWindowCenterTest.kt +++ b/app/src/test/java/de/jeanlucmakiola/calendula/widget/MonthWindowCenterTest.kt @@ -9,8 +9,8 @@ class MonthWindowCenterTest { fun `the loaded window always reaches two months past the shown one`() { for (index in -40..40) { val center = monthWindowCenter(index) - assertThat(index - 2).isAtLeast(center - 3) - assertThat(index + 2).isAtMost(center + 3) + assertThat(index - 2).isAtLeast(center - MONTH_WIDGET_PAD_MONTHS) + assertThat(index + 2).isAtMost(center + MONTH_WIDGET_PAD_MONTHS) } } diff --git a/floret-kit b/floret-kit index 818dfa0..1ae8dd2 160000 --- a/floret-kit +++ b/floret-kit @@ -1 +1 @@ -Subproject commit 818dfa0398cb0c8339c1680cc8da7ef9df6b8f0d +Subproject commit 1ae8dd2b3548a7877a7690e4110c98ee86498f10