From 77e103c66c7bccd356bc3a0fd714a3aee159a541 Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Wed, 21 Oct 2026 15:26:05 +0200 Subject: [PATCH 01/24] Move widgets onto floret-kit glance, reload on every redraw (#420, #229) --- app/build.gradle.kts | 1 + app/src/main/AndroidManifest.xml | 14 +- .../jeanlucmakiola/calendula/CalendulaApp.kt | 6 +- .../data/calendar/CalendarRepository.kt | 4 + .../data/calendar/CalendarRepositoryImpl.kt | 6 + .../ui/settings/SettingsViewModel.kt | 109 ++-------- .../calendula/widget/WidgetData.kt | 112 ++++------- .../calendula/widget/WidgetEntryPoint.kt | 1 + .../calendula/widget/WidgetRedraw.kt | 77 +++++++ .../widget/WidgetRolloverScheduler.kt | 115 ----------- .../calendula/widget/WidgetTheme.kt | 36 +--- .../calendula/widget/WidgetUpdateReceiver.kt | 126 ++---------- .../calendula/widget/agenda/AgendaWidget.kt | 117 ++--------- .../widget/agenda/AgendaWidgetReceiver.kt | 10 +- .../calendula/widget/month/MonthWidget.kt | 47 ++--- .../widget/month/MonthWidgetReceiver.kt | 10 +- .../calendula/widget/MonthWindowCenterTest.kt | 23 +++ .../widget/WidgetRolloverSchedulerTest.kt | 189 ------------------ floret-kit | 2 +- 19 files changed, 254 insertions(+), 751 deletions(-) create mode 100644 app/src/main/java/de/jeanlucmakiola/calendula/widget/WidgetRedraw.kt delete mode 100644 app/src/main/java/de/jeanlucmakiola/calendula/widget/WidgetRolloverScheduler.kt create mode 100644 app/src/test/java/de/jeanlucmakiola/calendula/widget/MonthWindowCenterTest.kt delete mode 100644 app/src/test/java/de/jeanlucmakiola/calendula/widget/WidgetRolloverSchedulerTest.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 16ea6ab..27c551c 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -181,6 +181,7 @@ dependencies { implementation("de.jeanlucmakiola.floret:core-android") implementation("de.jeanlucmakiola.floret:identity") implementation("de.jeanlucmakiola.floret:components") + implementation("de.jeanlucmakiola.floret:glance") implementation(libs.kotlinx.coroutines.core) debugImplementation(libs.androidx.ui.tooling) diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index b7cf5c5..7dc63c5 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -343,12 +343,13 @@ + diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/CalendulaApp.kt b/app/src/main/java/de/jeanlucmakiola/calendula/CalendulaApp.kt index 3f9b88c..ac17606 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/CalendulaApp.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/CalendulaApp.kt @@ -10,7 +10,8 @@ import de.jeanlucmakiola.calendula.data.contacts.SpecialDatesScheduler import de.jeanlucmakiola.calendula.data.contacts.SpecialDatesSyncWorker import de.jeanlucmakiola.calendula.data.reminders.ReminderMaintenanceScheduler import de.jeanlucmakiola.calendula.data.reminders.ReminderMaintenanceWorker -import de.jeanlucmakiola.calendula.widget.WidgetRolloverScheduler +import de.jeanlucmakiola.calendula.widget.syncWidgetRollover +import de.jeanlucmakiola.calendula.widget.widgetEntryPoint import de.jeanlucmakiola.floret.crash.CrashConfig import de.jeanlucmakiola.floret.crash.CrashReporter import kotlinx.coroutines.CoroutineScope @@ -48,6 +49,7 @@ class CalendulaApp : Application() { reconcileCalendarVisibility() startReminderDelivery() reconcileWidgetRollover() + widgetEntryPoint().widgetRefreshCoordinator().start() } /** @@ -58,7 +60,7 @@ class CalendulaApp : Application() { */ private fun reconcileWidgetRollover() { CoroutineScope(SupervisorJob() + Dispatchers.Default).launch { - WidgetRolloverScheduler.sync(this@CalendulaApp) + syncWidgetRollover(this@CalendulaApp) } } diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/CalendarRepository.kt b/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/CalendarRepository.kt index d140a75..8d946ec 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/CalendarRepository.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/CalendarRepository.kt @@ -18,6 +18,10 @@ interface CalendarRepository { /** Re-query every open [calendars] / [instances] flow. */ fun refresh() + + /** Emits whenever [instances] would re-query: a provider change, an own write, or a visibility change. */ + val changes: Flow + suspend fun eventDetail(eventId: Long): EventDetail /** diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/CalendarRepositoryImpl.kt b/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/CalendarRepositoryImpl.kt index 27c5166..2ffa916 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/CalendarRepositoryImpl.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/CalendarRepositoryImpl.kt @@ -87,6 +87,12 @@ class CalendarRepositoryImpl @Inject constructor( } } + override val changes: Flow = merge( + ticks, + prefs.pendingDisabledCalendarIds.distinctUntilChanged().drop(1).map {}, + prefs.hiddenCalendarIds.distinctUntilChanged().drop(1).map {}, + ) + /** * Re-query signal for everything filtered by visibility: the provider's own * notifications, plus every change to the pending switch-off set. diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/SettingsViewModel.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/SettingsViewModel.kt index cbbe707..c98e092 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/SettingsViewModel.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/SettingsViewModel.kt @@ -3,9 +3,6 @@ package de.jeanlucmakiola.calendula.ui.settings import android.content.Context import android.net.Uri import android.os.Build -import androidx.glance.appwidget.GlanceAppWidgetManager -import androidx.glance.appwidget.state.updateAppWidgetState -import androidx.glance.appwidget.updateAll import androidx.datastore.preferences.core.Preferences import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope @@ -42,7 +39,6 @@ import de.jeanlucmakiola.calendula.domain.EventFormField import de.jeanlucmakiola.calendula.domain.FontRole import de.jeanlucmakiola.calendula.domain.contacts.SpecialDateType import de.jeanlucmakiola.calendula.ui.agenda.AgendaRange -import de.jeanlucmakiola.calendula.ui.agenda.storageValue import de.jeanlucmakiola.calendula.ui.common.CalendarView import de.jeanlucmakiola.calendula.ui.common.TimelineHours import de.jeanlucmakiola.calendula.ui.common.TimelineScale @@ -50,12 +46,7 @@ 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 de.jeanlucmakiola.calendula.widget.agenda.AGENDA_PAST_DISPLAY_KEY -import de.jeanlucmakiola.calendula.widget.agenda.AGENDA_RANGE_KEY -import de.jeanlucmakiola.calendula.widget.agenda.AGENDA_SHOW_TODAY_STATE_KEY -import de.jeanlucmakiola.calendula.widget.agenda.AGENDA_SIZE_KEY -import de.jeanlucmakiola.calendula.widget.agenda.AgendaWidget -import de.jeanlucmakiola.calendula.widget.month.MonthWidget +import de.jeanlucmakiola.calendula.widget.redrawWidgets import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableSharedFlow @@ -181,10 +172,17 @@ class SettingsViewModel @Inject constructor( private val _fontImportFailed = MutableSharedFlow(extraBufferCapacity = 1) val fontImportFailed: SharedFlow = _fontImportFailed - // Serialises widget redraws: concurrent updateAll calls coalesce around a - // stale read and can leave the widget on an intermediate value. + // Serialises widget redraws, so a quick run of setting changes lands in order. private val widgetRefreshMutex = Mutex() + /** Saves a setting the widgets draw with, then reloads them. */ + private fun saveAndRedrawWidgets(block: suspend SettingsPrefs.() -> Unit) { + viewModelScope.launch { + prefs.block() + widgetRefreshMutex.withLock { redrawWidgets(appContext) } + } + } + /** Contact special-dates sub-page (issue #15). */ val specialDatesState: StateFlow = prefs.data.map { stored -> val templates = stored.specialDatesTitleTemplates @@ -267,16 +265,7 @@ class SettingsViewModel @Inject constructor( fun setDynamicColor(enabled: Boolean) = save { setDynamicColor(enabled) } - fun setSoftenColors(enabled: Boolean) { - viewModelScope.launch { - prefs.setSoftenCalendarColors(enabled) - // Both widgets paint event colours through the same softener. - widgetRefreshMutex.withLock { - AgendaWidget().updateAll(appContext) - MonthWidget().updateAll(appContext) - } - } - } + fun setSoftenColors(enabled: Boolean) = saveAndRedrawWidgets { setSoftenCalendarColors(enabled) } fun setFont(role: FontRole, token: String) = save { when (role) { @@ -304,70 +293,20 @@ class SettingsViewModel @Inject constructor( } } - fun setWeekStart(pref: WeekStartPref) { - viewModelScope.launch { - prefs.setWeekStart(pref) - // Both widgets depend on the week start (month weekday header; the - // agenda widget's "this week" range). - widgetRefreshMutex.withLock { - AgendaWidget().updateAll(appContext) - MonthWidget().updateAll(appContext) - } - } - } + fun setWeekStart(pref: WeekStartPref) = saveAndRedrawWidgets { setWeekStart(pref) } fun setAgendaScreenRange(range: AgendaRange) = save { setAgendaScreenRange(range) } - fun setAgendaWidgetRange(range: AgendaRange) { - viewModelScope.launch { - prefs.setAgendaWidgetRange(range) - widgetRefreshMutex.withLock { - // Push into each instance's Glance state and recompose: - // updateAll alone won't re-run provideGlance's data preamble. - val manager = GlanceAppWidgetManager(appContext) - manager.getGlanceIds(AgendaWidget::class.java).forEach { id -> - updateAppWidgetState(appContext, id) { it[AGENDA_RANGE_KEY] = range.storageValue() } - } - AgendaWidget().updateAll(appContext) - } - } - } + fun setAgendaWidgetRange(range: AgendaRange) = saveAndRedrawWidgets { setAgendaWidgetRange(range) } fun setAgendaShowRangeBar(enabled: Boolean) = save { setAgendaShowRangeBar(enabled) } - /** - * Set the size the agenda widget draws its text at (#51), via the same - * Glance-state path as [setAgendaWidgetRange]. Agenda-only: the month widget - * sizes itself from the space it is given (#103). - */ - fun setWidgetSize(size: WidgetSize) { - viewModelScope.launch { - prefs.setWidgetSize(size) - widgetRefreshMutex.withLock { - val manager = GlanceAppWidgetManager(appContext) - manager.getGlanceIds(AgendaWidget::class.java).forEach { id -> - updateAppWidgetState(appContext, id) { it[AGENDA_SIZE_KEY] = size.name } - } - AgendaWidget().updateAll(appContext) - } - } - } + /** The size the agenda widget draws its text at (#51); the month widget sizes itself (#103). */ + fun setWidgetSize(size: WidgetSize) = saveAndRedrawWidgets { setWidgetSize(size) } - fun setAgendaShowToday(enabled: Boolean) { - viewModelScope.launch { - prefs.setAgendaShowToday(enabled) - // Same Glance-state path as setPastEventDisplay. - widgetRefreshMutex.withLock { - val manager = GlanceAppWidgetManager(appContext) - manager.getGlanceIds(AgendaWidget::class.java).forEach { id -> - updateAppWidgetState(appContext, id) { it[AGENDA_SHOW_TODAY_STATE_KEY] = enabled } - } - AgendaWidget().updateAll(appContext) - } - } - } + fun setAgendaShowToday(enabled: Boolean) = saveAndRedrawWidgets { setAgendaShowToday(enabled) } - fun setTimeFormat(pref: TimeFormatPref) = save { setTimeFormat(pref) } + fun setTimeFormat(pref: TimeFormatPref) = saveAndRedrawWidgets { setTimeFormat(pref) } fun setMultiDayCount(count: Int) = save { setMultiDayCount(count) } @@ -396,19 +335,7 @@ class SettingsViewModel @Inject constructor( } } - fun setPastEventDisplay(mode: PastEventDisplay) { - viewModelScope.launch { - prefs.setPastEventDisplay(mode) - // Same Glance-state path as setAgendaWidgetRange. - widgetRefreshMutex.withLock { - val manager = GlanceAppWidgetManager(appContext) - manager.getGlanceIds(AgendaWidget::class.java).forEach { id -> - updateAppWidgetState(appContext, id) { it[AGENDA_PAST_DISPLAY_KEY] = mode.name } - } - AgendaWidget().updateAll(appContext) - } - } - } + fun setPastEventDisplay(mode: PastEventDisplay) = saveAndRedrawWidgets { setPastEventDisplay(mode) } fun setDimCompletedEvents(enabled: Boolean) = save { setDimCompletedEvents(enabled) } 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 e902b59..259ffba 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/widget/WidgetData.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/widget/WidgetData.kt @@ -51,14 +51,7 @@ sealed interface AgendaWidgetData { data object NeedsPermission : AgendaWidgetData data class Ready( val today: LocalDate, - /** - * Days with events across the **widest** selectable window. The displayed - * range is sliced from this in the composition (read reactively from - * Glance state), so a range change is pure recomposition rather than a - * flaky widget session restart — the same approach the month widget uses. - * Kept unfiltered: hiding/dimming finished events also happens reactively - * in the composition, against [now], for the same reliable-update reason. - */ + /** Days with events across [range], unfiltered; hiding finished ones happens in the composition. */ val days: List, /** Resolved clock convention for event time labels (the time-format pref). */ val is24Hour: Boolean, @@ -66,97 +59,62 @@ sealed interface AgendaWidgetData { val soften: Boolean, /** First day of the week, for the calendar-aligned "this week" range. */ val weekStart: DayOfWeek, - /** Saved range pref — the fallback when an instance has no Glance state yet. */ - val savedRange: AgendaRange, - /** Saved past-event display pref — the fallback before Glance state is set. */ - val savedPastDisplay: PastEventDisplay, - /** - * Saved "always show today" pref (#35) — anchors today at the top with a - * placeholder even when it has no events left. The fallback read reactively - * from Glance state before an instance has its own state set. - */ - val savedShowToday: Boolean, - /** Saved widget size step (#103) — the fallback before Glance state is set. */ - val savedWidgetSize: WidgetSize, - /** Last day [days] covers; a wider range picked later needs a reload. */ - val loadedThrough: LocalDate, + val range: AgendaRange, + val pastDisplay: PastEventDisplay, + /** "Always show today" (#35): anchor today at the top even with no events left. */ + val showToday: Boolean, + val widgetSize: WidgetSize, /** Snapshot instant the data was read at, for "has this event ended?" tests. */ val now: Instant, ) : AgendaWidgetData } /** - * Source data for the month widget: a wide window of instances plus the - * week-start preference and today. The widget computes each displayed month's - * grid from this in-memory list (via `layoutMonthWeeks`) as the user pages, - * so month navigation is pure recomposition — no reload, no flaky widget - * session restart. + * Source data for the month widget: a window of instances plus the week-start + * preference and today. The widget computes each displayed month's grid from + * this in-memory list (via `layoutMonthWeeks`) as the user pages, so paging + * inside [months] is pure recomposition. */ sealed interface MonthWidgetSource { data object NeedsPermission : MonthWidgetSource data class Ready( val today: LocalDate, val weekStart: DayOfWeek, + val soften: Boolean, val instances: List, /** Month indices (`year * 12 + monthOrdinal`) whose grids [instances] covers. */ val months: IntRange, ) : MonthWidgetSource } -/** - * Process-lived cache of the month window. Month navigation can re-run - * `provideGlance`, and re-querying on every arrow tap is what made paging feel - * sluggish — so we load once and reuse the same snapshot for every nearby month. Invalidated by - * [invalidateMonthWidgetCache] when calendar data changes (the freshness - * receiver), and automatically when the day rolls over (the `today` guard). - */ -internal object MonthWidgetCache { - @Volatile - var data: MonthWidgetSource.Ready? = null -} - -internal fun invalidateMonthWidgetCache() { - MonthWidgetCache.data = null -} - /** * 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. - * - * Loads [range] (the widget's own, or the saved pref when null) rather than the - * widest selectable window (#363). */ -internal suspend fun Context.loadAgendaWidgetData(range: AgendaRange? = null): AgendaWidgetData { +internal suspend fun Context.loadAgendaWidgetData(): AgendaWidgetData { if (!hasCalendarPermission()) return AgendaWidgetData.NeedsPermission val zone = systemZone() val anchor = today(zone) val ep = widgetEntryPoint() val prefs = ep.settingsPrefs() - val savedRange = prefs.agendaWidgetRange.first() - val savedPastDisplay = prefs.pastEventDisplay.first() - val showToday = prefs.agendaShowToday.first() + val range = prefs.agendaWidgetRange.first() val weekStart = prefs.weekStart.first().resolveFirstDay(Locale.getDefault()) - // The displayed range is still sliced in the composition from Glance state; - // a wider one picked later reloads there (see loadedThrough). - val dayCount = (range ?: savedRange).dayCount(anchor, weekStart) + val dayCount = 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() - val is24Hour = prefs.timeFormat.first() - .is24Hour(android.text.format.DateFormat.is24HourFormat(this)) - val soften = prefs.softenCalendarColors.first() return AgendaWidgetData.Ready( today = anchor, days = groupAgendaDays(anchor, windowEnd, instances, zone), - is24Hour = is24Hour, - soften = soften, + is24Hour = prefs.timeFormat.first() + .is24Hour(android.text.format.DateFormat.is24HourFormat(this)), + soften = prefs.softenCalendarColors.first(), weekStart = weekStart, - savedRange = savedRange, - savedPastDisplay = savedPastDisplay, - savedShowToday = showToday, - savedWidgetSize = prefs.widgetSize.first(), - loadedThrough = windowEnd, + range = range, + pastDisplay = prefs.pastEventDisplay.first(), + showToday = prefs.agendaShowToday.first(), + widgetSize = prefs.widgetSize.first(), now = Clock.System.now(), ) } @@ -165,17 +123,21 @@ internal fun monthIndexOf(date: LocalDate): Int = date.year * 12 + date.month.or private fun firstDayOfMonthIndex(index: Int): LocalDate = LocalDate(index / 12, index % 12 + 1, 1) -/** Read backing the month widget's grid for [centerIndex] and the months around it. */ -internal suspend fun Context.loadMonthWidgetSource(centerIndex: Int? = null): MonthWidgetSource { +/** + * The month the widget loads its window around while [index] is shown. Snapped + * to steps of three, so paging only reloads every third month, and the window + * always reaches at least two months past [index] either way. + */ +internal fun monthWindowCenter(index: Int): Int = Math.floorDiv(index, 3) * 3 + 1 + +/** Read backing the month widget's grid for the window around [centerIndex]. */ +internal suspend fun Context.loadMonthWidgetSource(centerIndex: Int): MonthWidgetSource { if (!hasCalendarPermission()) return MonthWidgetSource.NeedsPermission val zone = systemZone() - val anchor = today(zone) - val center = centerIndex ?: monthIndexOf(anchor) - // Reuse the cached window unless the day changed (then it's stale for "today"). - MonthWidgetCache.data?.let { if (it.today == anchor && center in it.months) return it } val ep = widgetEntryPoint() - val weekStart = ep.settingsPrefs().weekStart.first().resolveFirstDay(Locale.getDefault()) - val months = (center - MONTH_WIDGET_PAD_MONTHS)..(center + MONTH_WIDGET_PAD_MONTHS) + val prefs = ep.settingsPrefs() + val weekStart = prefs.weekStart.first().resolveFirstDay(Locale.getDefault()) + 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) .minus(1, DateTimeUnit.WEEK) @@ -184,11 +146,11 @@ internal suspend fun Context.loadMonthWidgetSource(centerIndex: Int? = null): Mo .plus(2, DateTimeUnit.WEEK) .atTime(23, 59, 59) .toInstant(zone) - val instances = ep.calendarRepository().instances(from..to).first() return MonthWidgetSource.Ready( - today = anchor, + today = today(zone), weekStart = weekStart, - instances = instances, + soften = prefs.softenCalendarColors.first(), + instances = ep.calendarRepository().instances(from..to).first(), months = months, - ).also { MonthWidgetCache.data = it } + ) } diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/widget/WidgetEntryPoint.kt b/app/src/main/java/de/jeanlucmakiola/calendula/widget/WidgetEntryPoint.kt index 177576c..e509067 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/widget/WidgetEntryPoint.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/widget/WidgetEntryPoint.kt @@ -21,6 +21,7 @@ import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs interface WidgetEntryPoint { fun calendarRepository(): CalendarRepository fun settingsPrefs(): SettingsPrefs + fun widgetRefreshCoordinator(): WidgetRefreshCoordinator } internal fun Context.widgetEntryPoint(): WidgetEntryPoint = diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/widget/WidgetRedraw.kt b/app/src/main/java/de/jeanlucmakiola/calendula/widget/WidgetRedraw.kt new file mode 100644 index 0000000..360f0f4 --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/calendula/widget/WidgetRedraw.kt @@ -0,0 +1,77 @@ +package de.jeanlucmakiola.calendula.widget + +import android.app.PendingIntent +import android.content.Context +import android.content.Intent +import de.jeanlucmakiola.calendula.data.calendar.CalendarRepository +import de.jeanlucmakiola.calendula.data.di.ApplicationScope +import de.jeanlucmakiola.calendula.widget.agenda.AgendaWidget +import de.jeanlucmakiola.calendula.widget.agenda.AgendaWidgetReceiver +import de.jeanlucmakiola.calendula.widget.month.MonthWidget +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 dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import javax.inject.Inject +import javax.inject.Singleton + +/** Every widget receiver; the set [WidgetRollover] checks for placed instances. */ +internal val WIDGET_RECEIVER_CLASSES: List> = listOf( + MonthWidgetReceiver::class.java, + AgendaWidgetReceiver::class.java, +) + +/** The app's own midnight wake-up (#228). */ +internal const val ACTION_WIDGET_ROLLOVER = "de.jeanlucmakiola.calendula.widget.ROLLOVER" + +/** Fixed: there is only ever one rollover alarm, and re-arming must replace it. */ +private const val ROLLOVER_REQUEST_CODE = 0x0DA1 + +internal fun widgetRolloverPendingIntent(context: Context): PendingIntent = PendingIntent.getBroadcast( + context, + ROLLOVER_REQUEST_CODE, + Intent(context, WidgetUpdateReceiver::class.java).setAction(ACTION_WIDGET_ROLLOVER), + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE, +) + +/** Arm the next midnight rollover, or cancel it when no widget is placed. Idempotent. */ +internal fun syncWidgetRollover(context: Context) = + WidgetRollover.sync(context, WIDGET_RECEIVER_CLASSES, ::widgetRolloverPendingIntent) + +/** + * Reloads and redraws every placed widget, a running session included: the + * refresh stamp moves, and both widgets key their data load on it (#420). + */ +internal suspend fun redrawWidgets(context: Context) { + AgendaWidget().updatePlaced(context, AgendaWidgetReceiver::class.java, bumpRefreshStamp = true) + MonthWidget().updatePlaced(context, MonthWidgetReceiver::class.java, bumpRefreshStamp = true) +} + +/** + * Redraws the widgets on every calendar change the app itself sees (its own + * writes above all), so they don't wait for `PROVIDER_CHANGED`, which arrives + * seconds late or not at all (#420, #229). Started once from `CalendulaApp`. + */ +@Singleton +class WidgetRefreshCoordinator @Inject constructor( + @ApplicationContext private val context: Context, + private val repository: CalendarRepository, + @ApplicationScope private val scope: CoroutineScope, +) { + private var started = false + + fun start() { + if (started) return + started = true + val refresher = WidgetRefresher(scope) { + if (WidgetRollover.hasPlacedWidgets(context, WIDGET_RECEIVER_CLASSES)) redrawWidgets(context) + } + scope.launch(Dispatchers.IO) { + repository.changes.collect { refresher.request() } + } + } +} diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/widget/WidgetRolloverScheduler.kt b/app/src/main/java/de/jeanlucmakiola/calendula/widget/WidgetRolloverScheduler.kt deleted file mode 100644 index b08c987..0000000 --- a/app/src/main/java/de/jeanlucmakiola/calendula/widget/WidgetRolloverScheduler.kt +++ /dev/null @@ -1,115 +0,0 @@ -package de.jeanlucmakiola.calendula.widget - -import android.app.AlarmManager -import android.app.PendingIntent -import android.appwidget.AppWidgetManager -import android.content.ComponentName -import android.content.Context -import android.content.Intent -import androidx.core.content.getSystemService -import de.jeanlucmakiola.calendula.widget.agenda.AgendaWidgetReceiver -import de.jeanlucmakiola.calendula.widget.month.MonthWidgetReceiver -import kotlinx.datetime.DateTimeUnit -import kotlinx.datetime.TimeZone -import kotlinx.datetime.atStartOfDayIn -import kotlinx.datetime.plus -import kotlinx.datetime.toLocalDateTime -import kotlin.time.Clock -import kotlin.time.Duration.Companion.hours -import kotlin.time.Duration.Companion.seconds -import kotlin.time.Instant - -/** - * Holds the app's own wake-up for the next local midnight, so the home-screen - * widgets roll "today" over on the day boundary (#228). - * - * The widgets used to lean on `ACTION_DATE_CHANGED`, which is not an exempted - * implicit broadcast — a manifest-declared receiver has not been given it since - * Android 8, leaving only the throttled `updatePeriodMillis`. - * - * Exactly one alarm exists at a time and every firing re-arms the next, the same - * shape as [de.jeanlucmakiola.calendula.data.reminders.ReminderAlarmScheduler]. - * Deliberately **inexact**: `setAndAllowWhileIdle` needs no permission and - * survives doze (plain `set` does not), a rollover a few minutes late is - * invisible on a sleeping screen, and exact alarms stay reserved for snooze. - */ -object WidgetRolloverScheduler { - - /** - * Fire just *after* midnight: an alarm delivered a few milliseconds early - * would still read the old date and re-arm for an instant later. - */ - internal val ROLLOVER_SLACK = 5.seconds - - /** - * Arm the next rollover, or cancel a pending one when no widget is placed. - * Idempotent, so every trigger (boot, app start, widget added/removed, the - * rollover itself, a clock or timezone change) can just call it. - */ - fun sync(context: Context) { - val appContext = context.applicationContext - val alarmManager = appContext.getSystemService() ?: return - val pendingIntent = rolloverPendingIntent(appContext) - if (!hasPlacedWidgets(appContext)) { - alarmManager.cancel(pendingIntent) - return - } - val triggerAt = nextRolloverAt(Clock.System.now(), TimeZone.currentSystemDefault()) - alarmManager.setAndAllowWhileIdle( - AlarmManager.RTC_WAKEUP, triggerAt.toEpochMilliseconds(), pendingIntent, - ) - } - - /** - * The instant just after the next local midnight following [now] in [zone]. - * - * The *actual* start of day, not 00:00, so it holds where a DST jump means - * midnight never happens (Havana) and where a date-line move skips a whole - * local date (Apia, December 2011) — the loop walks on to the next real day. - * - * The mirror case — a zone rewinding *across* midnight, so the day starts - * twice — resolves to the earlier start and runs an hour ahead of the clock. - * No live tz entry does that (Brazil dropped DST in 2019) and - * `updatePeriodMillis` covers it, so it isn't worth state to detect. - */ - fun nextRolloverAt(now: Instant, zone: TimeZone): Instant { - val date = now.toLocalDateTime(zone).date - var days = 1 - while (days <= MAX_LOOKAHEAD_DAYS) { - val candidate = date.plus(days, DateTimeUnit.DAY).atStartOfDayIn(zone) + ROLLOVER_SLACK - if (candidate > now) return candidate - days++ - } - // Unreachable for any zone in the tz database. Deliberately an hour and - // not the slack: a 5-second retry would just hit this branch again and - // wake the device in a loop. - return now + 1.hours - } - - private fun hasPlacedWidgets(context: Context): Boolean { - val manager = AppWidgetManager.getInstance(context) ?: return false - return PROVIDERS.any { - manager.getAppWidgetIds(ComponentName(context, it)).isNotEmpty() - } - } - - private fun rolloverPendingIntent(context: Context): PendingIntent = - PendingIntent.getBroadcast( - context, - ROLLOVER_REQUEST_CODE, - Intent(context, WidgetUpdateReceiver::class.java) - .setAction(WidgetUpdateReceiver.ACTION_ROLLOVER), - PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE, - ) - - private val PROVIDERS = listOf( - MonthWidgetReceiver::class.java, - AgendaWidgetReceiver::class.java, - ) - - /** Fixed: there is only ever one rollover alarm, and re-arming must replace it. */ - private const val ROLLOVER_REQUEST_CODE = 0x0DA1 - - /** A gap of more than a couple of days does not exist in any tz database entry. */ - private const val MAX_LOOKAHEAD_DAYS = 3 -} diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/widget/WidgetTheme.kt b/app/src/main/java/de/jeanlucmakiola/calendula/widget/WidgetTheme.kt index d679fe5..3fba7de 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/widget/WidgetTheme.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/widget/WidgetTheme.kt @@ -1,39 +1,25 @@ package de.jeanlucmakiola.calendula.widget -import android.os.Build import androidx.compose.runtime.Composable -import androidx.glance.GlanceTheme -import androidx.glance.material3.ColorProviders 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.WidgetThemeMode /** - * Brand fallback for devices without Material You dynamic colour (API < 31). - * Reuses the exact same hand-tuned schemes as the in-app theme - * ([CalendulaLightFallback] / [CalendulaDarkFallback]) so a widget on an older - * device matches the app surface-for-surface. - */ -private val CalendulaGlanceColors = ColorProviders( - light = CalendulaLightFallback, - dark = CalendulaDarkFallback, -) - -/** - * Glance equivalent of `CalendulaTheme`. On API 31+ it follows the system's - * Material You palette (so the widget matches the home screen / the app's - * dynamic colour); below that it falls back to the brand scheme. Either way the - * widget draws only from M3 colour-role tokens (`GlanceTheme.colors.*`) — never - * a hardcoded colour — so it tracks light/dark automatically. + * Glance equivalent of `CalendulaTheme`: Material You on API 31+, the app's + * hand-tuned fallback schemes below that, following the system's light/dark. */ @Composable fun CalendulaGlanceTheme(content: @Composable () -> Unit) { - val colors = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { - GlanceTheme.colors - } else { - CalendulaGlanceColors - } - GlanceTheme(colors = colors, content = content) + FloretGlanceTheme( + lightScheme = CalendulaLightFallback, + darkScheme = CalendulaDarkFallback, + dynamicColor = true, + mode = WidgetThemeMode.SYSTEM, + content = content, + ) } /** 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 b4972aa..0182171 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/widget/WidgetUpdateReceiver.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/widget/WidgetUpdateReceiver.kt @@ -1,120 +1,26 @@ package de.jeanlucmakiola.calendula.widget -import android.content.BroadcastReceiver +import android.app.PendingIntent import android.content.Context import android.content.Intent -import android.os.SystemClock -import androidx.glance.appwidget.updateAll -import de.jeanlucmakiola.calendula.widget.agenda.AgendaWidget -import de.jeanlucmakiola.calendula.widget.month.MonthWidget -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.SupervisorJob -import kotlinx.coroutines.launch -import java.util.concurrent.atomic.AtomicLong +import de.jeanlucmakiola.floret.glance.FloretWidgetUpdateReceiver +import kotlin.time.Duration.Companion.minutes /** - * Redraws both home-screen widgets when their data goes stale, and keeps the - * midnight rollover alarm armed. Triggered by: - * - `PROVIDER_CHANGED` from the calendar provider — fires on any data change, - * so it covers both the app's own writes and external sync. - * - [ACTION_ROLLOVER], the app's own alarm from [WidgetRolloverScheduler] — - * the day boundary, so "today" highlighting and the agenda's past-event - * dimming move on (#228). - * - `TIME_SET` / `TIMEZONE_CHANGED` — the day boundary moved, so redraw *and* - * re-arm. - * - `BOOT_COMPLETED` / `MY_PACKAGE_REPLACED` — both wipe pending alarms; the - * latter is also what arms installs upgrading into the fix. - * - * `DATE_CHANGED` rides along as a free extra the system may or may not send — - * see [WidgetRolloverScheduler]. The backstops are `updatePeriodMillis` in the - * provider XML and the month widget's refresh button. - * - * Every action that dropped, consumed or invalidated the alarm re-arms it, and - * `PROVIDER_CHANGED` re-arms at most once every [DATA_REARM_INTERVAL_MILLIS] - * ([dataRearmDue]). The alarm is inexact, so the system may drop it outright in - * a restricted standby bucket; nothing in ordinary daily use would then notice, - * and the widget sits on yesterday's date. A calendar change is the one signal - * that keeps arriving — but it arrives on *every* write (each drag, each event - * of a thousand-event import), which is what the interval is for. - * - * Exported for the system broadcasts; an extra redraw from another app is - * harmless. + * Redraws both widgets when their data, day or language goes stale, and keeps + * the midnight rollover armed. `PROVIDER_CHANGED` covers changes made outside + * 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. */ -class WidgetUpdateReceiver : BroadcastReceiver() { - override fun onReceive(context: Context, intent: Intent) { - // Exported, so anything can reach it with an explicit intent. Nothing - // here crosses a trust boundary, but narrowing to the actions we asked - // for keeps a stray broadcast from costing two wide provider reads. - if (intent.action !in HANDLED_ACTIONS) return - val appContext = context.applicationContext - val rearm = intent.action in REARM_ACTIONS || - (intent.action == Intent.ACTION_PROVIDER_CHANGED && dataRearmDue()) - // The host sends APPWIDGET_UPDATE after the re-arm-only ones anyway, so - // redrawing there would only repeat the work in a cold process, at the - // moment the device is most contended. - val redraw = intent.action !in REARM_ONLY_ACTIONS - val pending = goAsync() - // Calendar data may have changed (sync / our own write) — drop the cached - // month window so the widgets reload fresh. Month paging does NOT call - // this, so arrow taps stay instant. - if (redraw) invalidateMonthWidgetCache() - CoroutineScope(SupervisorJob() + Dispatchers.IO).launch { - try { - // Re-armed first, so the next day boundary is covered whatever - // the redraw does — and off the receiving thread, since it is a - // handful of binder calls (`CalendulaApp` moves it for the same - // reason). - if (rearm) WidgetRolloverScheduler.sync(appContext) - if (redraw) { - AgendaWidget().updateAll(appContext) - MonthWidget().updateAll(appContext) - } - } finally { - pending.finish() - } - } - } +class WidgetUpdateReceiver : FloretWidgetUpdateReceiver() { + override val rolloverAction = ACTION_WIDGET_ROLLOVER + override val widgetReceiverClasses = WIDGET_RECEIVER_CLASSES + override val extraHandledActions = setOf(Intent.ACTION_PROVIDER_CHANGED) + override val extraRearmInterval = 15.minutes - companion object { - /** The app's own midnight wake-up; see [WidgetRolloverScheduler]. */ - const val ACTION_ROLLOVER = "de.jeanlucmakiola.calendula.widget.ROLLOVER" + override fun rolloverPendingIntent(context: Context): PendingIntent = widgetRolloverPendingIntent(context) - /** Both wipe pending alarms, and the host redraws the widgets itself after them. */ - private val REARM_ONLY_ACTIONS = setOf( - Intent.ACTION_BOOT_COMPLETED, - Intent.ACTION_MY_PACKAGE_REPLACED, - ) - - /** - * The actions that leave no alarm standing: the two above wipe it, the - * rollover consumed itself, and a clock, zone or day change moved the - * boundary the pending one was set for. - */ - internal val REARM_ACTIONS = REARM_ONLY_ACTIONS + setOf( - ACTION_ROLLOVER, - Intent.ACTION_TIME_CHANGED, - Intent.ACTION_TIMEZONE_CHANGED, - Intent.ACTION_DATE_CHANGED, - ) - - internal val HANDLED_ACTIONS = REARM_ACTIONS + Intent.ACTION_PROVIDER_CHANGED - - /** How rarely a calendar change may re-arm; see the class doc. */ - private const val DATA_REARM_INTERVAL_MILLIS = 15 * 60_000L - - /** Uptime of the last data-change re-arm, 0 for none in this process. */ - private val lastDataRearmMillis = AtomicLong(0L) - - /** - * Whether a calendar change may re-arm now — the backstop for an alarm - * the system dropped, throttled so a burst of writes costs one pass. - */ - private fun dataRearmDue(): Boolean { - val now = SystemClock.elapsedRealtime() - val last = lastDataRearmMillis.get() - val due = last == 0L || now - last >= DATA_REARM_INTERVAL_MILLIS - return due && lastDataRearmMillis.compareAndSet(last, now) - } - } + 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 548572c..cb98ecb 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 @@ -5,16 +5,9 @@ import de.jeanlucmakiola.calendula.widget.glanceDeclinedDecoration import android.content.Context import android.content.res.Configuration import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue import androidx.compose.ui.graphics.Color import androidx.compose.ui.unit.dp -import androidx.datastore.preferences.core.Preferences -import androidx.datastore.preferences.core.booleanPreferencesKey -import androidx.datastore.preferences.core.stringPreferencesKey import androidx.glance.ColorFilter import androidx.glance.GlanceId import androidx.glance.GlanceModifier @@ -33,8 +26,6 @@ import androidx.glance.appwidget.lazy.LazyColumn import androidx.glance.appwidget.lazy.items import androidx.glance.appwidget.provideContent import androidx.glance.background -import androidx.glance.currentState -import androidx.glance.appwidget.state.getAppWidgetState import androidx.glance.state.PreferencesGlanceStateDefinition import androidx.glance.layout.Alignment import androidx.glance.layout.Box @@ -53,15 +44,11 @@ import androidx.glance.text.TextStyle import de.jeanlucmakiola.calendula.MainActivity import de.jeanlucmakiola.calendula.R import de.jeanlucmakiola.calendula.data.prefs.PastEventDisplay -import de.jeanlucmakiola.calendula.data.prefs.parsePastEventDisplay import de.jeanlucmakiola.calendula.domain.EventInstance import de.jeanlucmakiola.calendula.domain.hasEnded -import de.jeanlucmakiola.calendula.ui.agenda.AgendaRange import de.jeanlucmakiola.calendula.ui.agenda.AgendaTimeLabel import de.jeanlucmakiola.calendula.ui.agenda.agendaTimeLabel import de.jeanlucmakiola.calendula.ui.agenda.anchorTodayIfMissing -import de.jeanlucmakiola.calendula.ui.agenda.dayCount -import de.jeanlucmakiola.calendula.ui.agenda.parseAgendaRange import de.jeanlucmakiola.calendula.ui.common.CalendarView import de.jeanlucmakiola.calendula.ui.common.EventDimAlpha import de.jeanlucmakiola.calendula.ui.common.formatTimeOfDay @@ -72,6 +59,9 @@ 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.redrawWidgets +import de.jeanlucmakiola.floret.glance.currentRefreshStamp +import de.jeanlucmakiola.floret.glance.rememberWidgetData import de.jeanlucmakiola.calendula.widget.systemZone import de.jeanlucmakiola.calendula.widget.today import kotlinx.datetime.DateTimeUnit @@ -82,40 +72,6 @@ import kotlinx.datetime.toLocalDateTime import kotlin.time.Instant import java.util.Locale -/** - * Per-instance Glance state key holding the agenda range (as [AgendaRange.storageValue]). - * The range is read reactively in the composition ([currentState]) so a settings - * change is reflected by plain recomposition — `updateAll` does NOT reliably - * re-run the `provideGlance` preamble for a live widget session, so loading the - * range there and slicing it would silently keep the stale value (mirrors the - * month widget's MONTH_INDEX_KEY approach). - */ -internal val AGENDA_RANGE_KEY = stringPreferencesKey("agenda_range") - -/** - * Per-instance Glance state key holding the past-event display mode (as - * [PastEventDisplay.name]). Read reactively in the composition for the same - * reason as [AGENDA_RANGE_KEY] — so toggling the setting reflects on the live - * widget without depending on the `provideGlance` preamble re-running. - */ -internal val AGENDA_PAST_DISPLAY_KEY = stringPreferencesKey("agenda_past_display") - -/** - * Per-instance Glance state key holding the "always show today" toggle (#35). - * Read reactively in the composition for the same reason as [AGENDA_RANGE_KEY] — - * so toggling the setting reflects on the live widget without depending on the - * `provideGlance` preamble re-running. - */ -internal val AGENDA_SHOW_TODAY_STATE_KEY = booleanPreferencesKey("agenda_show_today") - -/** - * Per-instance Glance state key holding the chosen [WidgetSize]. Read reactively - * in the composition for the same reason as [AGENDA_RANGE_KEY] — so changing the - * setting reflects on the live widget without depending on the `provideGlance` - * preamble re-running. - */ -internal val AGENDA_SIZE_KEY = stringPreferencesKey("widget_size") - /** * "Upcoming" agenda widget — a continuously scrolling list of the next ~30 days * of events grouped under day headers (the Google "Schedule" widget model). @@ -132,11 +88,13 @@ class AgendaWidget : GlanceAppWidget() { override val sizeMode = SizeMode.Single override suspend fun provideGlance(context: Context, id: GlanceId) { - val stored = getAppWidgetState(context, id)[AGENDA_RANGE_KEY] - val data = context.loadAgendaWidgetData(stored?.let { parseAgendaRange(it, AgendaRange.Month) }) + val initial = context.loadAgendaWidgetData() 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 by rememberWidgetData(initial, currentRefreshStamp()) { context.loadAgendaWidgetData() } CalendulaGlanceTheme { AgendaWidgetBody(data = data, dark = dark) } @@ -144,16 +102,10 @@ class AgendaWidget : GlanceAppWidget() { } } -/** - * Redraws the widget (header refresh button). Targets the tapped widget's own id - * rather than `updateAll`, whose provider-name lookup is empty in a process a tap - * woke from cold — see `ShiftMonthAction` (#18). A cold process re-reads the - * calendar in the `provideGlance` preamble; a live session only recomposes from - * the snapshot it already has (see [AGENDA_RANGE_KEY]). - */ +/** Reloads and redraws the widgets (header refresh button). */ class RefreshAgendaAction : ActionCallback { override suspend fun onAction(context: Context, glanceId: GlanceId, parameters: ActionParameters) { - AgendaWidget().update(context.applicationContext, glanceId) + redrawWidgets(context.applicationContext) } } @@ -176,14 +128,9 @@ private sealed interface AgendaRow { @Composable private fun AgendaWidgetBody(data: AgendaWidgetData, dark: Boolean) { - // Read reactively from per-instance Glance state, falling back to the saved - // pref for a freshly placed widget (#103, #51). - // The stripe is then re-resolved against the system font scale so it tracks the + // 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 savedSize = (data as? AgendaWidgetData.Ready)?.savedWidgetSize ?: WidgetSize.SMALL - val size = currentState(AGENDA_SIZE_KEY) - ?.let { stored -> WidgetSize.entries.firstOrNull { it.name == stored } } - ?: savedSize + val size = (data as? AgendaWidgetData.Ready)?.widgetSize ?: WidgetSize.SMALL val fontScale = androidx.glance.LocalContext.current.resources.configuration.fontScale val metrics = metricsFor(size).scaledForFont(fontScale) Column( @@ -196,27 +143,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 -> { - // Range read reactively from per-instance Glance state (falls back - // to the saved pref for a freshly placed widget), then the loaded - // window is sliced to it — pure recomposition. - val range = parseAgendaRange(currentState(AGENDA_RANGE_KEY), data.savedRange) - val context = androidx.glance.LocalContext.current - var loaded by remember(data) { mutableStateOf(data) } - // A range wider than what was loaded reloads in a live session, - // where provideGlance doesn't run again (#363). - LaunchedEffect(range) { - val end = loaded.today.plus( - range.dayCount(loaded.today, loaded.weekStart) - 1, - DateTimeUnit.DAY, - ) - if (end > loaded.loadedThrough) { - (context.loadAgendaWidgetData(range) as? AgendaWidgetData.Ready) - ?.let { loaded = it } - } - } - AgendaRows(data = loaded, range = range, dark = dark, metrics = metrics) - } + is AgendaWidgetData.Ready -> AgendaRows(data = data, dark = dark, metrics = metrics) } } } @@ -224,24 +151,16 @@ private fun AgendaWidgetBody(data: AgendaWidgetData, dark: Boolean) { @Composable private fun AgendaRows( data: AgendaWidgetData.Ready, - range: AgendaRange, dark: Boolean, metrics: AgendaMetrics, ) { - val pastDisplay = - parsePastEventDisplay(currentState(AGENDA_PAST_DISPLAY_KEY), data.savedPastDisplay) - val showToday = currentState(AGENDA_SHOW_TODAY_STATE_KEY) ?: data.savedShowToday - val rangeEnd = data.today.plus( - range.dayCount(data.today, data.weekStart) - 1, - DateTimeUnit.DAY, - ) - // Slice to the chosen range, then drop finished events (and any day - // they leave empty) when the mode is Hide — so "Upcoming" really is. - // Finally re-anchor today (as an empty placeholder day) when the - // "always show today" pref is on and today has no events left (#35). + val pastDisplay = data.pastDisplay + // Drop finished events (and any day they leave empty) when the mode is + // Hide — so "Upcoming" really is. Then re-anchor today (as an empty + // placeholder day) when the "always show today" pref is on and today has + // no events left (#35). val visibleDays = anchorTodayIfMissing( days = data.days - .filter { it.date <= rangeEnd } .let { days -> if (pastDisplay == PastEventDisplay.HIDE) { days.mapNotNull { day -> @@ -253,7 +172,7 @@ private fun AgendaRows( } }, today = data.today, - enabled = showToday, + enabled = data.showToday, ) if (visibleDays.isEmpty()) { WidgetMessage(R.string.agenda_empty_title, metrics) 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 736da00..294cbd8 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 @@ -4,7 +4,7 @@ import android.appwidget.AppWidgetManager import android.content.Context import androidx.glance.appwidget.GlanceAppWidget import androidx.glance.appwidget.GlanceAppWidgetReceiver -import de.jeanlucmakiola.calendula.widget.WidgetRolloverScheduler +import de.jeanlucmakiola.calendula.widget.syncWidgetRollover /** * Host-facing receiver for the agenda widget. Declared in the manifest with the @@ -17,16 +17,16 @@ class AgendaWidgetReceiver : GlanceAppWidgetReceiver() { /** First agenda widget placed — start rolling "today" over at midnight (#228). */ override fun onEnabled(context: Context) { super.onEnabled(context) - WidgetRolloverScheduler.sync(context) + syncWidgetRollover(context) } /** - * Last agenda widget removed. [WidgetRolloverScheduler.sync] cancels only if + * Last agenda widget removed. [syncWidgetRollover] cancels only if * no month widget is left either. */ override fun onDisabled(context: Context) { super.onDisabled(context) - WidgetRolloverScheduler.sync(context) + syncWidgetRollover(context) } /** @@ -39,6 +39,6 @@ class AgendaWidgetReceiver : GlanceAppWidgetReceiver() { appWidgetIds: IntArray, ) { super.onUpdate(context, appWidgetManager, appWidgetIds) - WidgetRolloverScheduler.sync(context) + 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 c72e851..d5d2b88 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 @@ -5,11 +5,7 @@ import de.jeanlucmakiola.calendula.widget.glanceDeclinedDecoration import android.content.Context import android.content.res.Configuration import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -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 @@ -66,11 +62,12 @@ import de.jeanlucmakiola.calendula.ui.common.eventInk import de.jeanlucmakiola.calendula.widget.CalendulaGlanceTheme import de.jeanlucmakiola.calendula.widget.MonthWidgetSource import de.jeanlucmakiola.calendula.widget.loadMonthWidgetSource +import de.jeanlucmakiola.calendula.widget.monthWindowCenter +import de.jeanlucmakiola.floret.glance.currentRefreshStamp +import de.jeanlucmakiola.floret.glance.rememberWidgetData import de.jeanlucmakiola.calendula.widget.monthIndexOf import de.jeanlucmakiola.calendula.widget.systemZone import de.jeanlucmakiola.calendula.widget.today -import de.jeanlucmakiola.calendula.widget.widgetEntryPoint -import kotlinx.coroutines.flow.first import androidx.compose.ui.unit.Dp import kotlinx.datetime.DayOfWeek import kotlinx.datetime.LocalDate @@ -129,15 +126,20 @@ class MonthWidget : GlanceAppWidget() { override suspend fun provideGlance(context: Context, id: GlanceId) { val shown = getAppWidgetState(context, id)[MONTH_INDEX_KEY] - val source = context.loadMonthWidgetSource(shown) + ?: currentMonthIndex(systemZone()) + val initial = context.loadMonthWidgetSource(monthWindowCenter(shown)) val dark = (context.resources.configuration.uiMode and Configuration.UI_MODE_NIGHT_MASK) == Configuration.UI_MODE_NIGHT_YES - // Read fresh (not through the cached source) so toggling the softener - // redraws with the new choice; it's one cheap DataStore read. - val soften = context.widgetEntryPoint().settingsPrefs().softenCalendarColors.first() 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 by rememberWidgetData(initial, center, currentRefreshStamp()) { + context.loadMonthWidgetSource(center) + } CalendulaGlanceTheme { - MonthWidgetBody(source = source, dark = dark, soften = soften) + MonthWidgetBody(source = source, index = index, dark = dark) } } } @@ -188,7 +190,7 @@ class ResetMonthAction : ActionCallback { } @Composable -private fun MonthWidgetBody(source: MonthWidgetSource, dark: Boolean, soften: Boolean) { +private fun MonthWidgetBody(source: MonthWidgetSource, index: Int, dark: Boolean) { Column( modifier = GlanceModifier .fillMaxSize() @@ -202,32 +204,21 @@ private fun MonthWidgetBody(source: MonthWidgetSource, dark: Boolean, soften: Bo } is MonthWidgetSource.Ready -> { val zone = systemZone() - val index = currentState(MONTH_INDEX_KEY) ?: currentMonthIndex(zone) val ym = yearMonthOf(index) - // Paging past the loaded months reloads around the new one in a - // live session, where provideGlance doesn't run again. - val context = LocalContext.current - var loaded by remember(source) { mutableStateOf(source) } - LaunchedEffect(index) { - if (index !in loaded.months) { - (context.loadMonthWidgetSource(index) as? MonthWidgetSource.Ready) - ?.let { loaded = it } - } - } // Column width from the live widget size, minus our H padding. val colW = (LocalSize.current.width - GRID_HPADDING * 2) / 7 - val weeks = layoutMonthWeeks(ym, loaded.weekStart, loaded.instances, zone) + val weeks = layoutMonthWeeks(ym, source.weekStart, source.instances, zone) - MonthHeader(label = monthLabel(ym, loaded.today.year)) + MonthHeader(label = monthLabel(ym, source.today.year)) Spacer(GlanceModifier.height(2.dp)) - WeekdayHeader(weekStart = loaded.weekStart, colW = colW) + WeekdayHeader(weekStart = source.weekStart, colW = colW) weeks.forEach { week -> WeekRow( week = week, currentMonth = ym.month, - today = loaded.today, + today = source.today, dark = dark, - soften = soften, + soften = source.soften, colW = colW, modifier = GlanceModifier.defaultWeight(), ) 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 08e4a3e..82830fa 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 @@ -4,7 +4,7 @@ import android.appwidget.AppWidgetManager import android.content.Context import androidx.glance.appwidget.GlanceAppWidget import androidx.glance.appwidget.GlanceAppWidgetReceiver -import de.jeanlucmakiola.calendula.widget.WidgetRolloverScheduler +import de.jeanlucmakiola.calendula.widget.syncWidgetRollover /** * Host-facing receiver for the month widget. Declared in the manifest with the @@ -16,16 +16,16 @@ class MonthWidgetReceiver : GlanceAppWidgetReceiver() { /** First month widget placed — start rolling "today" over at midnight (#228). */ override fun onEnabled(context: Context) { super.onEnabled(context) - WidgetRolloverScheduler.sync(context) + syncWidgetRollover(context) } /** - * Last month widget removed. [WidgetRolloverScheduler.sync] cancels only if + * Last month widget removed. [syncWidgetRollover] cancels only if * no agenda widget is left either. */ override fun onDisabled(context: Context) { super.onDisabled(context) - WidgetRolloverScheduler.sync(context) + syncWidgetRollover(context) } /** @@ -41,6 +41,6 @@ class MonthWidgetReceiver : GlanceAppWidgetReceiver() { appWidgetIds: IntArray, ) { super.onUpdate(context, appWidgetManager, appWidgetIds) - WidgetRolloverScheduler.sync(context) + 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 new file mode 100644 index 0000000..edcad5c --- /dev/null +++ b/app/src/test/java/de/jeanlucmakiola/calendula/widget/MonthWindowCenterTest.kt @@ -0,0 +1,23 @@ +package de.jeanlucmakiola.calendula.widget + +import com.google.common.truth.Truth.assertThat +import org.junit.jupiter.api.Test + +class MonthWindowCenterTest { + + @Test + 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) + } + } + + @Test + fun `paging within a step of three keeps the same window`() { + val year = 2026 * 12 + assertThat(monthWindowCenter(year)).isEqualTo(monthWindowCenter(year + 2)) + assertThat(monthWindowCenter(year + 3)).isNotEqualTo(monthWindowCenter(year + 2)) + } +} diff --git a/app/src/test/java/de/jeanlucmakiola/calendula/widget/WidgetRolloverSchedulerTest.kt b/app/src/test/java/de/jeanlucmakiola/calendula/widget/WidgetRolloverSchedulerTest.kt deleted file mode 100644 index 8242073..0000000 --- a/app/src/test/java/de/jeanlucmakiola/calendula/widget/WidgetRolloverSchedulerTest.kt +++ /dev/null @@ -1,189 +0,0 @@ -package de.jeanlucmakiola.calendula.widget - -import android.content.Intent -import com.google.common.truth.Truth.assertThat -import kotlinx.datetime.LocalDate -import kotlinx.datetime.LocalDateTime -import kotlinx.datetime.TimeZone -import kotlinx.datetime.atStartOfDayIn -import kotlinx.datetime.toInstant -import kotlinx.datetime.toLocalDateTime -import org.junit.jupiter.api.Test -import kotlin.time.Duration -import kotlin.time.Duration.Companion.hours -import kotlin.time.Duration.Companion.minutes -import kotlin.time.Instant - -/** - * The rollover alarm is what makes the widgets stop highlighting yesterday - * (#228), so the "when is the next local midnight" arithmetic is the one piece - * worth pinning down — especially where midnight is not 00:00. - */ -class WidgetRolloverSchedulerTest { - - private val berlin = TimeZone.of("Europe/Berlin") - - private fun at(local: String, zone: TimeZone): Instant = - LocalDateTime.parse(local).toInstant(zone) - - private fun nextRollover(local: String, zone: TimeZone = berlin): Instant = - WidgetRolloverScheduler.nextRolloverAt(at(local, zone), zone) - - // --- the ordinary day ---------------------------------------------------- - - @Test - fun `midday rolls over at the coming midnight`() { - val next = nextRollover("2026-08-27T12:00:00") - assertThat(next).isEqualTo(at("2026-08-28T00:00:05", berlin)) - } - - @Test - fun `a second before midnight still targets tonight, not tomorrow night`() { - val next = nextRollover("2026-08-27T23:59:59") - assertThat(next).isEqualTo(at("2026-08-28T00:00:05", berlin)) - } - - @Test - fun `at midnight exactly the target is the next day, never the current instant`() { - // Re-arming after a firing must move a whole day on, or the widget wakes - // itself in a tight loop. - val now = at("2026-08-28T00:00:00", berlin) - val next = WidgetRolloverScheduler.nextRolloverAt(now, berlin) - assertThat(next).isEqualTo(at("2026-08-29T00:00:05", berlin)) - assertThat(next - now).isGreaterThan(Duration.ZERO) - } - - @Test - fun `re-arming from the slack instant itself moves a full day on`() { - // What actually happens in practice: the receiver runs at midnight + slack. - val now = at("2026-08-28T00:00:05", berlin) - assertThat(WidgetRolloverScheduler.nextRolloverAt(now, berlin)) - .isEqualTo(at("2026-08-29T00:00:05", berlin)) - } - - @Test - fun `the result is always in the future for every minute of a day`() { - val zone = berlin - // Spans Berlin's 2024 spring-forward: the likeliest source of a target - // in the past, i.e. an alarm that fires immediately, forever. - var probe = LocalDateTime.parse("2024-03-29T00:00:00").toInstant(zone) - val end = LocalDateTime.parse("2024-04-01T00:00:00").toInstant(zone) - while (probe < end) { - assertThat(WidgetRolloverScheduler.nextRolloverAt(probe, zone)).isGreaterThan(probe) - probe += 1.minutes - } - } - - // --- daylight saving ----------------------------------------------------- - - @Test - fun `spring forward keeps the rollover one day away, not one hour short`() { - // Berlin skipped 02:00-03:00 on 31 March 2024, so that day was 23h long. - // A rollover computed as "now + 24h" would land at 01:00 on 1 April. - val now = at("2024-03-30T12:00:00", berlin) - val next = WidgetRolloverScheduler.nextRolloverAt(now, berlin) - assertThat(next).isEqualTo(at("2024-03-31T00:00:05", berlin)) - assertThat(next - now).isLessThan(24.hours) - } - - @Test - fun `fall back does not overshoot into the repeated hour`() { - // Berlin repeated 02:00-03:00 on 27 October 2024: a 25h day, so - // "now + 24h" would land at 23:00 on the 26th and never roll over. - val now = at("2024-10-26T12:00:00", berlin) - val next = WidgetRolloverScheduler.nextRolloverAt(now, berlin) - assertThat(next).isEqualTo(at("2024-10-27T00:00:05", berlin)) - assertThat(next.toLocalDateTime(berlin).date).isEqualTo(LocalDate.parse("2024-10-27")) - } - - @Test - fun `a zone that repeats midnight takes the first start of day`() { - // Sao Paulo used to end DST by moving 00:00 back to 23:00, so the day - // began twice. Pinned as the accepted trade: the widget runs an hour - // ahead until the next redraw. No live zone does this since 2019. - val saoPaulo = TimeZone.of("America/Sao_Paulo") - val next = WidgetRolloverScheduler.nextRolloverAt( - at("2018-02-16T12:00:00", saoPaulo), saoPaulo, - ) - val local = next.toLocalDateTime(saoPaulo) - assertThat(local.date).isEqualTo(LocalDate.parse("2018-02-17")) - assertThat(local.hour).isEqualTo(0) - } - - @Test - fun `a zone where midnight does not exist rolls over at the real start of day`() { - // Cuba starts DST at 00:00, so 11 March 2018 began at 01:00 in Havana. - // Targeting a literal 00:00 there would arm an instant on the wrong day. - val havana = TimeZone.of("America/Havana") - val next = WidgetRolloverScheduler.nextRolloverAt(at("2018-03-10T12:00:00", havana), havana) - val local = next.toLocalDateTime(havana) - assertThat(local.date).isEqualTo(LocalDate.parse("2018-03-11")) - assertThat(local.hour).isEqualTo(1) - assertThat(local.minute).isEqualTo(0) - // And it is genuinely the first instant of that date, not a guess. - assertThat(next).isEqualTo( - LocalDate.parse("2018-03-11").atStartOfDayIn(havana) + - WidgetRolloverScheduler.ROLLOVER_SLACK, - ) - } - - // --- timezone changes ---------------------------------------------------- - - @Test - fun `the same instant rolls over at different times in different zones`() { - // TIMEZONE_CHANGED must re-arm to the new local midnight: the arithmetic - // follows the zone, not a cached offset. - val instant = at("2026-08-27T12:00:00", berlin) - val tokyo = TimeZone.of("Asia/Tokyo") - val berlinNext = WidgetRolloverScheduler.nextRolloverAt(instant, berlin) - val tokyoNext = WidgetRolloverScheduler.nextRolloverAt(instant, tokyo) - assertThat(tokyoNext).isNotEqualTo(berlinNext) - assertThat(tokyoNext).isLessThan(berlinNext) - assertThat(tokyoNext.toLocalDateTime(tokyo).hour).isEqualTo(0) - } - - @Test - fun `a half-hour offset zone still lands on its own midnight`() { - val kathmandu = TimeZone.of("Asia/Kathmandu") - val next = WidgetRolloverScheduler.nextRolloverAt( - at("2026-08-27T12:00:00", kathmandu), kathmandu, - ) - val local = next.toLocalDateTime(kathmandu) - assertThat(local.date.toString()).isEqualTo("2026-08-28") - assertThat(local.hour).isEqualTo(0) - } - - // --- the wiring ---------------------------------------------------------- - - @Test - fun `the receiver actually handles the action the alarm is sent with`() { - // The single point where the whole fix would die silently: the alarm - // fires, the receiver drops it on the action guard, nothing redraws. - assertThat(WidgetUpdateReceiver.HANDLED_ACTIONS) - .contains(WidgetUpdateReceiver.ACTION_ROLLOVER) - } - - @Test - fun `a data change is handled, and re-arms only on the throttle`() { - // PROVIDER_CHANGED fires on every calendar write — each event of a bulk - // import, each drop of a drag — so it is not an unconditional re-arm. - // It is not dropped either: the alarm is inexact and the system may lose - // it, and this is the only signal that keeps arriving to notice. - assertThat(WidgetUpdateReceiver.HANDLED_ACTIONS) - .containsAtLeast(Intent.ACTION_PROVIDER_CHANGED, Intent.ACTION_DATE_CHANGED) - assertThat(WidgetUpdateReceiver.REARM_ACTIONS) - .doesNotContain(Intent.ACTION_PROVIDER_CHANGED) - } - - @Test - fun `everything that loses the alarm re-arms it`() { - assertThat(WidgetUpdateReceiver.REARM_ACTIONS).containsAtLeast( - WidgetUpdateReceiver.ACTION_ROLLOVER, - Intent.ACTION_BOOT_COMPLETED, - Intent.ACTION_MY_PACKAGE_REPLACED, - Intent.ACTION_TIME_CHANGED, - Intent.ACTION_TIMEZONE_CHANGED, - Intent.ACTION_DATE_CHANGED, - ) - } -} diff --git a/floret-kit b/floret-kit index 9f90027..acc983f 160000 --- a/floret-kit +++ b/floret-kit @@ -1 +1 @@ -Subproject commit 9f90027dcd9f1f7a0511bea462ecdf297387b912 +Subproject commit acc983f2ac4ca73769e28bc0d71da56293f42b2f From 225901ceee98ce0aa9c2bdc91ea9e75f612ad777 Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Wed, 21 Oct 2026 17:02:43 +0200 Subject: [PATCH 02/24] Share widget loads across sizes, redraw on own changes only (#420, #229) --- .../jeanlucmakiola/calendula/CalendulaApp.kt | 9 +++--- .../data/calendar/CalendarRepository.kt | 4 +-- .../data/calendar/CalendarRepositoryImpl.kt | 11 +++++-- .../ui/settings/SettingsViewModel.kt | 28 ++++------------ .../calendula/widget/WidgetData.kt | 13 ++------ .../calendula/widget/WidgetRedraw.kt | 32 ++++++++++++------- .../calendula/widget/agenda/AgendaWidget.kt | 15 +++++---- .../widget/agenda/AgendaWidgetReceiver.kt | 2 ++ .../calendula/widget/month/MonthWidget.kt | 16 ++++++---- .../widget/month/MonthWidgetReceiver.kt | 2 ++ floret-kit | 2 +- 11 files changed, 69 insertions(+), 65 deletions(-) diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/CalendulaApp.kt b/app/src/main/java/de/jeanlucmakiola/calendula/CalendulaApp.kt index ac17606..64e321a 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/CalendulaApp.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/CalendulaApp.kt @@ -48,19 +48,20 @@ class CalendulaApp : Application() { reconcileSpecialDates() reconcileCalendarVisibility() startReminderDelivery() - reconcileWidgetRollover() - widgetEntryPoint().widgetRefreshCoordinator().start() + startWidgets() } /** * Re-arm the widgets' midnight rollover from whatever is actually placed - * (#228). Idempotent, and it covers what no broadcast reaches — an alarm + * (#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 * 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 reconcileWidgetRollover() { + private fun startWidgets() { CoroutineScope(SupervisorJob() + Dispatchers.Default).launch { syncWidgetRollover(this@CalendulaApp) + widgetEntryPoint().widgetRefreshCoordinator().start() } } diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/CalendarRepository.kt b/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/CalendarRepository.kt index 8d946ec..3838327 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/CalendarRepository.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/CalendarRepository.kt @@ -19,8 +19,8 @@ interface CalendarRepository { /** Re-query every open [calendars] / [instances] flow. */ fun refresh() - /** Emits whenever [instances] would re-query: a provider change, an own write, or a visibility change. */ - val changes: Flow + /** Emits on the app's own writes and visibility changes; not on provider notifications or [refresh]. */ + val localChanges: Flow suspend fun eventDetail(eventId: Long): EventDetail diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/CalendarRepositoryImpl.kt b/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/CalendarRepositoryImpl.kt index 2ffa916..ec6017d 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/CalendarRepositoryImpl.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/CalendarRepositoryImpl.kt @@ -78,17 +78,24 @@ class CalendarRepositoryImpl @Inject constructor( ticks.tryEmit(Unit) } + private val writes = MutableSharedFlow( + replay = 0, + extraBufferCapacity = 1, + onBufferOverflow = BufferOverflow.DROP_OLDEST, + ) + /** Run a provider write on [io], then re-query whether or not it threw. */ private suspend fun write(block: suspend () -> T): T = withContext(io) { try { block() } finally { refresh() + writes.tryEmit(Unit) } } - override val changes: Flow = merge( - ticks, + override val localChanges: Flow = merge( + writes, prefs.pendingDisabledCalendarIds.distinctUntilChanged().drop(1).map {}, prefs.hiddenCalendarIds.distinctUntilChanged().drop(1).map {}, ) diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/SettingsViewModel.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/SettingsViewModel.kt index c98e092..1d4b93a 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/SettingsViewModel.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/SettingsViewModel.kt @@ -46,7 +46,6 @@ 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 de.jeanlucmakiola.calendula.widget.redrawWidgets import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableSharedFlow @@ -58,8 +57,6 @@ import kotlinx.coroutines.flow.catch import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.map import kotlinx.coroutines.launch -import kotlinx.coroutines.sync.Mutex -import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.withContext import javax.inject.Inject @@ -172,17 +169,6 @@ class SettingsViewModel @Inject constructor( private val _fontImportFailed = MutableSharedFlow(extraBufferCapacity = 1) val fontImportFailed: SharedFlow = _fontImportFailed - // Serialises widget redraws, so a quick run of setting changes lands in order. - private val widgetRefreshMutex = Mutex() - - /** Saves a setting the widgets draw with, then reloads them. */ - private fun saveAndRedrawWidgets(block: suspend SettingsPrefs.() -> Unit) { - viewModelScope.launch { - prefs.block() - widgetRefreshMutex.withLock { redrawWidgets(appContext) } - } - } - /** Contact special-dates sub-page (issue #15). */ val specialDatesState: StateFlow = prefs.data.map { stored -> val templates = stored.specialDatesTitleTemplates @@ -265,7 +251,7 @@ class SettingsViewModel @Inject constructor( fun setDynamicColor(enabled: Boolean) = save { setDynamicColor(enabled) } - fun setSoftenColors(enabled: Boolean) = saveAndRedrawWidgets { setSoftenCalendarColors(enabled) } + fun setSoftenColors(enabled: Boolean) = save { setSoftenCalendarColors(enabled) } fun setFont(role: FontRole, token: String) = save { when (role) { @@ -293,20 +279,20 @@ class SettingsViewModel @Inject constructor( } } - fun setWeekStart(pref: WeekStartPref) = saveAndRedrawWidgets { setWeekStart(pref) } + fun setWeekStart(pref: WeekStartPref) = save { setWeekStart(pref) } fun setAgendaScreenRange(range: AgendaRange) = save { setAgendaScreenRange(range) } - fun setAgendaWidgetRange(range: AgendaRange) = saveAndRedrawWidgets { setAgendaWidgetRange(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) = saveAndRedrawWidgets { setWidgetSize(size) } + fun setWidgetSize(size: WidgetSize) = save { setWidgetSize(size) } - fun setAgendaShowToday(enabled: Boolean) = saveAndRedrawWidgets { setAgendaShowToday(enabled) } + fun setAgendaShowToday(enabled: Boolean) = save { setAgendaShowToday(enabled) } - fun setTimeFormat(pref: TimeFormatPref) = saveAndRedrawWidgets { setTimeFormat(pref) } + fun setTimeFormat(pref: TimeFormatPref) = save { setTimeFormat(pref) } fun setMultiDayCount(count: Int) = save { setMultiDayCount(count) } @@ -335,7 +321,7 @@ class SettingsViewModel @Inject constructor( } } - fun setPastEventDisplay(mode: PastEventDisplay) = saveAndRedrawWidgets { setPastEventDisplay(mode) } + fun setPastEventDisplay(mode: PastEventDisplay) = save { setPastEventDisplay(mode) } fun setDimCompletedEvents(enabled: Boolean) = save { setDimCompletedEvents(enabled) } 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 259ffba..6dfe5fd 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/widget/WidgetData.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/widget/WidgetData.kt @@ -9,7 +9,6 @@ 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 @@ -51,15 +50,12 @@ sealed interface AgendaWidgetData { data object NeedsPermission : AgendaWidgetData data class Ready( val today: LocalDate, - /** Days with events across [range], unfiltered; hiding finished ones happens in the composition. */ + /** Days with events across the configured range, unfiltered; hiding finished ones happens in the composition. */ val days: List, /** Resolved clock convention for event time labels (the time-format pref). */ val is24Hour: Boolean, /** Whether event colours are softened to pastels, or shown raw (#36). */ val soften: Boolean, - /** First day of the week, for the calendar-aligned "this week" range. */ - val weekStart: DayOfWeek, - val range: AgendaRange, val pastDisplay: PastEventDisplay, /** "Always show today" (#35): anchor today at the top even with no events left. */ val showToday: Boolean, @@ -73,7 +69,7 @@ sealed interface AgendaWidgetData { * Source data for the month widget: a window of instances plus the week-start * preference and today. The widget computes each displayed month's grid from * this in-memory list (via `layoutMonthWeeks`) as the user pages, so paging - * inside [months] is pure recomposition. + * inside the loaded window is pure recomposition. */ sealed interface MonthWidgetSource { data object NeedsPermission : MonthWidgetSource @@ -82,8 +78,6 @@ 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 } @@ -110,8 +104,6 @@ internal suspend fun Context.loadAgendaWidgetData(): AgendaWidgetData { is24Hour = prefs.timeFormat.first() .is24Hour(android.text.format.DateFormat.is24HourFormat(this)), soften = prefs.softenCalendarColors.first(), - weekStart = weekStart, - range = range, pastDisplay = prefs.pastEventDisplay.first(), showToday = prefs.agendaShowToday.first(), widgetSize = prefs.widgetSize.first(), @@ -151,6 +143,5 @@ internal suspend fun Context.loadMonthWidgetSource(centerIndex: Int): MonthWidge weekStart = weekStart, soften = prefs.softenCalendarColors.first(), 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 360f0f4..a62c72a 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/widget/WidgetRedraw.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/widget/WidgetRedraw.kt @@ -5,6 +5,7 @@ import android.content.Context import android.content.Intent import de.jeanlucmakiola.calendula.data.calendar.CalendarRepository import de.jeanlucmakiola.calendula.data.di.ApplicationScope +import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs import de.jeanlucmakiola.calendula.widget.agenda.AgendaWidget import de.jeanlucmakiola.calendula.widget.agenda.AgendaWidgetReceiver import de.jeanlucmakiola.calendula.widget.month.MonthWidget @@ -15,6 +16,10 @@ import de.jeanlucmakiola.floret.glance.updatePlaced import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers +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 javax.inject.Inject import javax.inject.Singleton @@ -52,26 +57,31 @@ internal suspend fun redrawWidgets(context: Context) { } /** - * Redraws the widgets on every calendar change the app itself sees (its own - * writes above all), so they don't wait for `PROVIDER_CHANGED`, which arrives - * seconds late or not at all (#420, #229). Started once from `CalendulaApp`. + * 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`. */ @Singleton class WidgetRefreshCoordinator @Inject constructor( @ApplicationContext private val context: Context, private val repository: CalendarRepository, + private val prefs: SettingsPrefs, @ApplicationScope private val scope: CoroutineScope, ) { - private var started = false - fun start() { - if (started) return - started = true - val refresher = WidgetRefresher(scope) { - if (WidgetRollover.hasPlacedWidgets(context, WIDGET_RECEIVER_CLASSES)) redrawWidgets(context) - } + 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) { - repository.changes.collect { refresher.request() } + merge(repository.localChanges, *settings.toTypedArray()).collect { refresher.request() } } } } 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 cb98ecb..7fc4ace 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 @@ -5,7 +5,6 @@ import de.jeanlucmakiola.calendula.widget.glanceDeclinedDecoration import android.content.Context import android.content.res.Configuration import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue import androidx.compose.ui.graphics.Color import androidx.compose.ui.unit.dp import androidx.glance.ColorFilter @@ -59,9 +58,10 @@ 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.redrawWidgets +import de.jeanlucmakiola.floret.glance.WidgetData import de.jeanlucmakiola.floret.glance.currentRefreshStamp -import de.jeanlucmakiola.floret.glance.rememberWidgetData +import de.jeanlucmakiola.floret.glance.refreshStamp +import de.jeanlucmakiola.floret.glance.updateWithRefresh import de.jeanlucmakiola.calendula.widget.systemZone import de.jeanlucmakiola.calendula.widget.today import kotlinx.datetime.DateTimeUnit @@ -88,13 +88,14 @@ class AgendaWidget : GlanceAppWidget() { override val sizeMode = SizeMode.Single override suspend fun provideGlance(context: Context, id: GlanceId) { - val initial = context.loadAgendaWidgetData() + 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 by rememberWidgetData(initial, currentRefreshStamp()) { context.loadAgendaWidgetData() } + val data = widgetData.current(currentRefreshStamp()) { context.loadAgendaWidgetData() } CalendulaGlanceTheme { AgendaWidgetBody(data = data, dark = dark) } @@ -102,10 +103,10 @@ class AgendaWidget : GlanceAppWidget() { } } -/** Reloads and redraws the widgets (header refresh button). */ +/** Reloads and redraws the tapped widget (header refresh button). */ class RefreshAgendaAction : ActionCallback { override suspend fun onAction(context: Context, glanceId: GlanceId, parameters: ActionParameters) { - redrawWidgets(context.applicationContext) + AgendaWidget().updateWithRefresh(context.applicationContext, glanceId) } } 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 294cbd8..5d24f62 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 @@ -5,6 +5,7 @@ import android.content.Context import androidx.glance.appwidget.GlanceAppWidget import androidx.glance.appwidget.GlanceAppWidgetReceiver import de.jeanlucmakiola.calendula.widget.syncWidgetRollover +import de.jeanlucmakiola.floret.glance.reloadOnUpdate /** * Host-facing receiver for the agenda widget. Declared in the manifest with the @@ -39,6 +40,7 @@ class AgendaWidgetReceiver : GlanceAppWidgetReceiver() { appWidgetIds: IntArray, ) { super.onUpdate(context, appWidgetManager, appWidgetIds) + 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 d5d2b88..8834e48 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 @@ -5,7 +5,6 @@ import de.jeanlucmakiola.calendula.widget.glanceDeclinedDecoration import android.content.Context import android.content.res.Configuration import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.datastore.preferences.core.Preferences @@ -63,8 +62,10 @@ import de.jeanlucmakiola.calendula.widget.CalendulaGlanceTheme import de.jeanlucmakiola.calendula.widget.MonthWidgetSource import de.jeanlucmakiola.calendula.widget.loadMonthWidgetSource import de.jeanlucmakiola.calendula.widget.monthWindowCenter +import de.jeanlucmakiola.floret.glance.WidgetData import de.jeanlucmakiola.floret.glance.currentRefreshStamp -import de.jeanlucmakiola.floret.glance.rememberWidgetData +import de.jeanlucmakiola.floret.glance.refreshStamp +import de.jeanlucmakiola.floret.glance.updateWithRefresh import de.jeanlucmakiola.calendula.widget.monthIndexOf import de.jeanlucmakiola.calendula.widget.systemZone import de.jeanlucmakiola.calendula.widget.today @@ -127,7 +128,10 @@ class MonthWidget : GlanceAppWidget() { override suspend fun provideGlance(context: Context, id: GlanceId) { val shown = getAppWidgetState(context, id)[MONTH_INDEX_KEY] ?: currentMonthIndex(systemZone()) - val initial = context.loadMonthWidgetSource(monthWindowCenter(shown)) + 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 provideContent { @@ -135,7 +139,7 @@ class MonthWidget : GlanceAppWidget() { // 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 by rememberWidgetData(initial, center, currentRefreshStamp()) { + val source = widgetData.current(center, currentRefreshStamp()) { context.loadMonthWidgetSource(center) } CalendulaGlanceTheme { @@ -181,11 +185,11 @@ class ShiftMonthAction : ActionCallback { } } -/** Jump the displayed month back to the current month. See [ShiftMonthAction]. */ +/** Jump the displayed month back to the current month, reloading it. See [ShiftMonthAction]. */ class ResetMonthAction : ActionCallback { override suspend fun onAction(context: Context, glanceId: GlanceId, parameters: ActionParameters) { updateAppWidgetState(context, glanceId) { prefs -> prefs.remove(MONTH_INDEX_KEY) } - MonthWidget().update(context.applicationContext, glanceId) + MonthWidget().updateWithRefresh(context.applicationContext, glanceId) } } 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 82830fa..b1c42c1 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 @@ -5,6 +5,7 @@ import android.content.Context import androidx.glance.appwidget.GlanceAppWidget import androidx.glance.appwidget.GlanceAppWidgetReceiver import de.jeanlucmakiola.calendula.widget.syncWidgetRollover +import de.jeanlucmakiola.floret.glance.reloadOnUpdate /** * Host-facing receiver for the month widget. Declared in the manifest with the @@ -41,6 +42,7 @@ class MonthWidgetReceiver : GlanceAppWidgetReceiver() { appWidgetIds: IntArray, ) { super.onUpdate(context, appWidgetManager, appWidgetIds) + reloadOnUpdate(context, appWidgetIds) syncWidgetRollover(context) } } diff --git a/floret-kit b/floret-kit index acc983f..818dfa0 160000 --- a/floret-kit +++ b/floret-kit @@ -1 +1 @@ -Subproject commit acc983f2ac4ca73769e28bc0d71da56293f42b2f +Subproject commit 818dfa0398cb0c8339c1680cc8da7ef9df6b8f0d From 35f5dc16666ebb19de7b1f2d23d09a922ff7eeff Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Wed, 21 Oct 2026 17:36:30 +0200 Subject: [PATCH 03/24] 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 From b206e0e6a592b1216c710ae6862760ce6960e64a Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Wed, 21 Oct 2026 17:48:24 +0200 Subject: [PATCH 04/24] Bump floret-kit onto current main --- floret-kit | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/floret-kit b/floret-kit index 1ae8dd2..b498e9c 160000 --- a/floret-kit +++ b/floret-kit @@ -1 +1 @@ -Subproject commit 1ae8dd2b3548a7877a7690e4110c98ee86498f10 +Subproject commit b498e9cc04ef679b5838992cc76bd37a466f2588 From 3471447ab69f80331c77788e63a6e81fdf95cd04 Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Wed, 21 Oct 2026 17:57:04 +0200 Subject: [PATCH 05/24] Pin floret-kit to main (glance refresh helpers, #18) --- floret-kit | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/floret-kit b/floret-kit index b498e9c..96509d0 160000 --- a/floret-kit +++ b/floret-kit @@ -1 +1 @@ -Subproject commit b498e9cc04ef679b5838992cc76bd37a466f2588 +Subproject commit 96509d0f9d6bd8b8cea65fbe06b93d2ae6088b55 From a87ebc33b6b6a4d6f74dc2a88171b4e277eae855 Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Wed, 21 Oct 2026 18:18:01 +0200 Subject: [PATCH 06/24] Month widget: tap a day anywhere opens it, fit busy months into Glance's view limit (#386, #372) --- .../calendula/widget/month/MonthGrid.kt | 109 +++++++++++++ .../calendula/widget/month/MonthWidget.kt | 143 ++++-------------- .../calendula/widget/month/MonthGridTest.kt | 78 ++++++++++ 3 files changed, 214 insertions(+), 116 deletions(-) create mode 100644 app/src/main/java/de/jeanlucmakiola/calendula/widget/month/MonthGrid.kt create mode 100644 app/src/test/java/de/jeanlucmakiola/calendula/widget/month/MonthGridTest.kt diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/widget/month/MonthGrid.kt b/app/src/main/java/de/jeanlucmakiola/calendula/widget/month/MonthGrid.kt new file mode 100644 index 0000000..3a8915e --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/calendula/widget/month/MonthGrid.kt @@ -0,0 +1,109 @@ +package de.jeanlucmakiola.calendula.widget.month + +import de.jeanlucmakiola.calendula.domain.EventInstance +import de.jeanlucmakiola.calendula.ui.month.MonthWeek +import de.jeanlucmakiola.calendula.ui.month.seating +import de.jeanlucmakiola.calendula.ui.month.spanAt +import kotlinx.datetime.LocalDate + +/** Event rows (lanes) a week draws at most before the rest collapse into "+N". */ +internal const val MAX_LANES = 3 + +/** + * Glance gives each rendering 500 view ids and fails the whole widget past them + * (#372). The grid stays under this, leaving room for Glance's own wrappers. + */ +internal const val VIEW_BUDGET = 440 + +/** Views outside the week rows: the header row with its title and three icons, a spacer and the weekday row. */ +internal const val FIXED_VIEWS = 18 + +/** A bar occupying [cols] columns, or — with a null event — that many blank ones. */ +internal data class LaneCell(val event: EventInstance?, val cols: Int) + +/** A "+[count]" label one column wide, or — with a zero count — a gap [cols] wide. */ +internal data class OverflowCell(val count: Int, val cols: Int) + +/** + * Lane [lane] split into bars and the gaps between them, consecutive blank + * columns merged into one gap: the day-open taps come from the strip underneath, + * so a gap needs no per-column view and a mostly-empty lane collapses to a spacer. + */ +internal fun MonthWeek.laneCells(lane: Int, laneCap: Int): List { + val cells = mutableListOf() + var gap = 0 + var col = 0 + while (col < days.size) { + val seat = seating(col, laneCap).seated.firstOrNull { it.lane == lane } + if (seat == null) { + gap += 1 + col += 1 + continue + } + if (gap > 0) cells += LaneCell(null, gap) + gap = 0 + val cols = if (seat.bar) (spanAt(col, lane)?.endCol ?: col) - col + 1 else 1 + cells += LaneCell(seat.event, cols) + col += cols + } + if (gap > 0) cells += LaneCell(null, gap) + return cells +} + +/** How many lanes this week fills from the top under [laneCap]; empty lanes below are not drawn. */ +internal fun MonthWeek.usedLanes(laneCap: Int): Int = + days.indices.maxOfOrNull { col -> seating(col, laneCap).seated.maxOfOrNull { it.lane + 1 } ?: 0 } ?: 0 + +/** Events per day that no lane had room for — the "+N" counts, 0 where all fit. */ +internal fun MonthWeek.hiddenPerDay(laneCap: Int): List = + days.indices.map { col -> seating(col, laneCap).hidden.size } + +/** The "+N" row as labels and merged gaps; empty when nothing is hidden. */ +internal fun overflowCells(hidden: List): List { + if (hidden.none { it > 0 }) return emptyList() + val cells = mutableListOf() + var gap = 0 + hidden.forEach { count -> + if (count == 0) { + gap += 1 + } else { + if (gap > 0) cells += OverflowCell(0, gap) + gap = 0 + cells += OverflowCell(count, 1) + } + } + if (gap > 0) cells += OverflowCell(0, gap) + return cells +} + +/** The views `WeekRow` emits for this week with [laneCap] lanes, counted the way it draws them. */ +internal fun MonthWeek.viewCount(laneCap: Int, today: LocalDate): Int { + val strips = 1 + days.size + val numbers = 1 + days.size + if (today in days) 2 else 0 + val lanes = (0 until usedLanes(laneCap)).sumOf { lane -> + 2 + laneCells(lane, laneCap).sumOf { if (it.event == null) 1 else 3 } + } + val overflow = overflowCells(hiddenPerDay(laneCap)).let { if (it.isEmpty()) 0 else 1 + it.size } + // The week's Box, its content Column and the spacer under the numbers. + return 3 + strips + numbers + lanes + overflow +} + +/** + * The lanes each week may draw so the grid fits [budget] views: the most + * expensive week gives up its lowest lane first, its events folding into "+N", + * until the whole month fits (#372). + */ +internal fun laneCaps( + weeks: List, + today: LocalDate, + budget: Int = VIEW_BUDGET - FIXED_VIEWS, +): List { + val caps = MutableList(weeks.size) { MAX_LANES } + val costs = MutableList(weeks.size) { weeks[it].viewCount(MAX_LANES, today) } + while (costs.sum() > budget) { + val busiest = weeks.indices.filter { caps[it] > 0 }.maxByOrNull { costs[it] } ?: break + caps[busiest] -= 1 + costs[busiest] = weeks[busiest].viewCount(caps[busiest], today) + } + return caps +} 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 e39a984..25a6fae 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 @@ -86,8 +86,6 @@ import java.util.Locale /** Per-widget state: the displayed month as `year * 12 + monthOrdinal`. */ private val MONTH_INDEX_KEY = intPreferencesKey("month_index") -/** Event rows (lanes) shown per week before the rest collapse into "+N". */ -private const val MAX_LANES = 3 private val LANE_HEIGHT = 14.dp private val DAY_NUMBER_HEIGHT = 18.dp private val GRID_HPADDING = 8.dp @@ -225,13 +223,15 @@ private fun MonthWidgetBody(source: MonthWidgetSource, index: Int) { // Column width from the live widget size, minus our H padding. val colW = (LocalSize.current.width - GRID_HPADDING * 2) / 7 val weeks = layoutMonthWeeks(ym, source.weekStart, source.instances, zone) + val caps = laneCaps(weeks, source.today) MonthHeader(label = monthLabel(ym, source.today.year)) Spacer(GlanceModifier.height(2.dp)) WeekdayHeader(weekStart = source.weekStart, colW = colW) - weeks.forEach { week -> + weeks.forEachIndexed { i, week -> WeekRow( week = week, + laneCap = caps[i], currentMonth = ym.month, today = source.today, soften = source.soften, @@ -341,6 +341,7 @@ private fun weekdayNarrowNames(weekStart: DayOfWeek): List { @Composable private fun WeekRow( week: MonthWeek, + laneCap: Int, currentMonth: Month, today: LocalDate, soften: Boolean, @@ -348,13 +349,13 @@ private fun WeekRow( modifier: GlanceModifier, ) { val context = LocalContext.current - val hidden = week.hiddenPerDay() + val overflow = overflowCells(week.hiddenPerDay(laneCap)) Box(modifier = modifier.fillMaxWidth()) { // Day-open targets, one full-height strip per column, sitting *under* the // content: a tap anywhere in a day still opens it, as in the app, without // every cell in the column carrying a click target of its own. The content - // above is not clickable, so touches fall through to this layer; only an - // event bar opts out to open its own detail. + // above is not clickable, event bars included, so touches fall through to + // this layer and a day opens wherever it is tapped (#386). Row(modifier = GlanceModifier.fillMaxSize()) { week.days.forEach { date -> Box( @@ -385,42 +386,21 @@ private fun WeekRow( // One lane row per event row. A multi-day span is a single Box spanning // 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, soften = soften, colW = colW) + // The grid's view budget caps the lanes per week (#372); what doesn't + // fit folds into the "+N" row. Mirrored one to one by viewCount. + repeat(week.usedLanes(laneCap)) { lane -> + LaneRow(cells = week.laneCells(lane, laneCap), soften = soften, colW = colW) Spacer(GlanceModifier.height(1.dp)) } - if (hidden.any { it > 0 }) OverflowRow(hidden = hidden, colW = colW) + if (overflow.isNotEmpty()) OverflowRow(cells = overflow, colW = colW) } } } -/** - * How many lanes this week actually fills, counting from the top. The grid draws - * that many rows instead of a fixed [MAX_LANES]; an empty lane costs three views - * per day and most weeks leave the last two empty. - */ -private fun MonthWeek.usedLanes(): Int { - for (lane in MAX_LANES - 1 downTo 0) { - val filled = spans.any { it.lane == lane } || - days.indices.any { col -> timedEventAt(this, lane, col, days[col]) != null } - if (filled) return lane + 1 - } - return 0 -} - -/** Events per day that no lane had room for — the "+N" counts, 0 where all fit. */ -private fun MonthWeek.hiddenPerDay(): List = days.mapIndexed { col, date -> - val shownSpans = spans.count { col in it.startCol..it.endCol && it.lane < MAX_LANES } - val freeSlots = (MAX_LANES - shownSpans).coerceAtLeast(0) - val timedShown = minOf(freeSlots, timedByDay[date].orEmpty().size) - ((countByDay[date] ?: 0) - shownSpans - timedShown).coerceAtLeast(0) -} - /** * Open [date]'s day view rooted in the month view (so back returns to the month * grid) — the same target the in-app month grid uses when a day cell is tapped. - * Shared by every tappable part of a day column so, as in the app, a tap anywhere - * on a day opens it; only an event bar on top opts out to open its own detail. + * The only tap target in a day column, so a tap anywhere on a day opens it. */ private fun openDayAction(context: Context, date: LocalDate) = actionStartActivity(MainActivity.openDateIntent(context, date, CalendarView.Month)) @@ -459,50 +439,10 @@ private fun DayNumber(date: LocalDate, isToday: Boolean, inMonth: Boolean, colW: } } -/** A bar occupying [cols] columns, or — with a null event — that many blank ones. */ -private data class LaneCell(val event: EventInstance?, val cols: Int) - -/** - * A lane split into bars and the gaps between them, with consecutive blank columns - * merged into one gap: the week's day-open taps come from the strip underneath, so - * a gap needs no per-column view and a mostly-empty lane collapses to a spacer. - */ -private fun MonthWeek.laneCells(lane: Int): List { - val cells = mutableListOf() - var gap = 0 - var col = 0 - fun closeGap() { - if (gap > 0) cells += LaneCell(null, gap) - gap = 0 - } - while (col < 7) { - val span = spans.firstOrNull { it.lane == lane && col in it.startCol..it.endCol } - val timed = if (span == null) timedEventAt(this, lane, col, days[col]) else null - when { - span != null -> { - closeGap() - cells += LaneCell(span.event, span.endCol - col + 1) - col = span.endCol + 1 - } - timed != null -> { - closeGap() - cells += LaneCell(timed, 1) - col += 1 - } - else -> { - gap += 1 - col += 1 - } - } - } - closeGap() - return cells -} - @Composable -private fun LaneRow(week: MonthWeek, lane: Int, soften: Boolean, colW: Dp) { +private fun LaneRow(cells: List, soften: Boolean, colW: Dp) { Row(modifier = GlanceModifier.fillMaxWidth()) { - week.laneCells(lane).forEach { cell -> + cells.forEach { cell -> if (cell.event == null) { Spacer(GlanceModifier.width(colW * cell.cols).height(LANE_HEIGHT)) } else { @@ -526,19 +466,7 @@ private fun SpanBar(event: EventInstance, soften: Boolean, width: Dp) { modifier = GlanceModifier .width(width) .height(LANE_HEIGHT) - .padding(horizontal = 1.dp) - // Tap an event bar to open its detail, rooted in the month view. - .clickable( - actionStartActivity( - MainActivity.openEventIntent( - context = context, - eventId = event.eventId, - beginMillis = event.start.toEpochMilliseconds(), - endMillis = event.end.toEpochMilliseconds(), - source = CalendarView.Month, - ), - ), - ), + .padding(horizontal = 1.dp), ) { Box( modifier = GlanceModifier @@ -563,40 +491,23 @@ private fun SpanBar(event: EventInstance, soften: Boolean, width: Dp) { /** The "+N" row, drawn only for weeks that hide something, gaps merged as in a lane. */ @Composable -private fun OverflowRow(hidden: List, colW: Dp) { +private fun OverflowRow(cells: List, colW: Dp) { Row(modifier = GlanceModifier.fillMaxWidth()) { - var gap = 0 - hidden.forEach { count -> - if (count == 0) { - gap += 1 - return@forEach + cells.forEach { cell -> + if (cell.count == 0) { + Spacer(GlanceModifier.width(colW * cell.cols).height(LANE_HEIGHT)) + } else { + Text( + text = "+${cell.count}", + maxLines = 1, + style = TextStyle(color = GlanceTheme.colors.onSurfaceVariant, fontSize = 9.sp), + modifier = GlanceModifier.width(colW).padding(start = 3.dp), + ) } - if (gap > 0) { - Spacer(GlanceModifier.width(colW * gap).height(LANE_HEIGHT)) - gap = 0 - } - Text( - text = "+$count", - maxLines = 1, - style = TextStyle(color = GlanceTheme.colors.onSurfaceVariant, fontSize = 9.sp), - modifier = GlanceModifier.width(colW).padding(start = 3.dp), - ) } - if (gap > 0) Spacer(GlanceModifier.width(colW * gap).height(LANE_HEIGHT)) } } -/** The timed single-day event that fills lane [lane] on day [col], if any. */ -private fun timedEventAt(week: MonthWeek, lane: Int, col: Int, date: LocalDate): EventInstance? { - val occupied = week.spans - .filter { col in it.startCol..it.endCol && it.lane < MAX_LANES } - .map { it.lane } - .toSet() - val freeSlots = (0 until MAX_LANES).filter { it !in occupied } - val timed = week.timedByDay[date].orEmpty() - return timed.getOrNull(freeSlots.indexOf(lane)) -} - @Composable private fun PermissionMessage() { val context = LocalContext.current diff --git a/app/src/test/java/de/jeanlucmakiola/calendula/widget/month/MonthGridTest.kt b/app/src/test/java/de/jeanlucmakiola/calendula/widget/month/MonthGridTest.kt new file mode 100644 index 0000000..f2751e5 --- /dev/null +++ b/app/src/test/java/de/jeanlucmakiola/calendula/widget/month/MonthGridTest.kt @@ -0,0 +1,78 @@ +package de.jeanlucmakiola.calendula.widget.month + +import com.google.common.truth.Truth.assertThat +import de.jeanlucmakiola.calendula.timedEvent +import de.jeanlucmakiola.calendula.ui.month.layoutMonthWeeks +import kotlinx.datetime.DateTimeUnit +import kotlinx.datetime.DayOfWeek +import kotlinx.datetime.LocalDate +import kotlinx.datetime.Month +import kotlinx.datetime.TimeZone +import kotlinx.datetime.YearMonth +import kotlinx.datetime.plus +import org.junit.jupiter.api.Test + +/** The month widget's view budget (#372). */ +class MonthGridTest { + + private val zone = TimeZone.UTC + private val jul26 = YearMonth(2026, Month.JULY) + private val today = LocalDate(2026, 7, 15) + + /** July 2026 with [perDay] timed events on every day of the grid. */ + private fun weeks(perDay: Int): List { + val first = LocalDate(2026, 6, 29) + var id = 0L + val events = (0 until 42).flatMap { offset -> + val day = first.plus(offset, DateTimeUnit.DAY) + (1..perDay).map { hour -> + timedEvent(day, hour, zone = zone, id = ++id, title = "E$id", color = 0xFFF44336.toInt()) + } + } + return layoutMonthWeeks(jul26, DayOfWeek.MONDAY, events, zone) + } + + @Test + fun `a quiet month keeps every lane`() { + val weeks = weeks(perDay = 1) + assertThat(laneCaps(weeks, today)).containsExactlyElementsIn(List(weeks.size) { MAX_LANES }) + } + + @Test + fun `a full month folds lanes until it fits the budget`() { + val weeks = weeks(perDay = 5) + val fullCost = weeks.sumOf { it.viewCount(MAX_LANES, today) } + assertThat(fullCost).isGreaterThan(VIEW_BUDGET - FIXED_VIEWS) + + val caps = laneCaps(weeks, today) + val cost = weeks.indices.sumOf { weeks[it].viewCount(caps[it], today) } + assertThat(cost).isAtMost(VIEW_BUDGET - FIXED_VIEWS) + assertThat(caps.any { it < MAX_LANES }).isTrue() + } + + @Test + fun `folded events are counted in the overflow`() { + val week = weeks(perDay = 5)[1] + val hidden = week.hiddenPerDay(laneCap = 1) + assertThat(hidden).containsExactlyElementsIn(List(7) { 4 }) + assertThat(week.usedLanes(laneCap = 1)).isEqualTo(1) + } + + @Test + fun `a lane of single-day events is one cell per day`() { + val cells = weeks(perDay = 1)[1].laneCells(lane = 0, laneCap = MAX_LANES) + assertThat(cells).hasSize(7) + assertThat(cells.all { it.event != null && it.cols == 1 }).isTrue() + } + + @Test + fun `overflow gaps are merged`() { + assertThat(overflowCells(listOf(0, 0, 2, 0, 0, 0, 1))).containsExactly( + OverflowCell(0, 2), + OverflowCell(2, 1), + OverflowCell(0, 3), + OverflowCell(1, 1), + ).inOrder() + assertThat(overflowCells(List(7) { 0 })).isEmpty() + } +} From 46ca2db63aadae12cc39aa1fc5e13c411d1674dc Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Wed, 21 Oct 2026 18:44:14 +0200 Subject: [PATCH 07/24] Month widget: count Glance's real view ids per Android version, test the budget on device (#372) --- .../widget/month/MonthWidgetViewBudgetTest.kt | 125 +++++++++++++ .../calendula/widget/month/MonthGrid.kt | 169 +++++++++++------- .../calendula/widget/month/MonthWidget.kt | 36 ++-- .../calendula/widget/month/MonthGridTest.kt | 107 +++++++---- 4 files changed, 313 insertions(+), 124 deletions(-) create mode 100644 app/src/androidTest/java/de/jeanlucmakiola/calendula/widget/month/MonthWidgetViewBudgetTest.kt diff --git a/app/src/androidTest/java/de/jeanlucmakiola/calendula/widget/month/MonthWidgetViewBudgetTest.kt b/app/src/androidTest/java/de/jeanlucmakiola/calendula/widget/month/MonthWidgetViewBudgetTest.kt new file mode 100644 index 0000000..f57f1d2 --- /dev/null +++ b/app/src/androidTest/java/de/jeanlucmakiola/calendula/widget/month/MonthWidgetViewBudgetTest.kt @@ -0,0 +1,125 @@ +package de.jeanlucmakiola.calendula.widget.month + +import android.Manifest +import android.content.ContentUris +import android.content.ContentValues +import android.net.Uri +import android.provider.CalendarContract +import android.util.Log +import android.view.View +import android.view.ViewGroup +import android.widget.FrameLayout +import android.widget.TextView +import androidx.compose.ui.unit.DpSize +import androidx.compose.ui.unit.dp +import androidx.glance.ExperimentalGlanceApi +import androidx.glance.appwidget.compose +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.platform.app.InstrumentationRegistry +import androidx.test.rule.GrantPermissionRule +import com.google.common.truth.Truth.assertThat +import kotlinx.coroutines.runBlocking +import org.junit.After +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import java.time.LocalDate +import java.time.ZoneId + +/** + * Renders the month widget for a packed month through Glance's own translator + * and counts what it inflates (#372): the view-id model in [WeekLayout] has to + * match what Glance really builds, or a busy month fails with "too many views". + */ +@OptIn(ExperimentalGlanceApi::class) +@RunWith(AndroidJUnit4::class) +class MonthWidgetViewBudgetTest { + + @get:Rule + val permissionRule: GrantPermissionRule = + GrantPermissionRule.grant(Manifest.permission.READ_CALENDAR, Manifest.permission.WRITE_CALENDAR) + + private val instrumentation = InstrumentationRegistry.getInstrumentation() + private val context = instrumentation.targetContext + private var calendarId = -1L + + private fun Uri.asSyncAdapter(): Uri = buildUpon() + .appendQueryParameter(CalendarContract.CALLER_IS_SYNCADAPTER, "true") + .appendQueryParameter(CalendarContract.Calendars.ACCOUNT_NAME, ACCOUNT) + .appendQueryParameter(CalendarContract.Calendars.ACCOUNT_TYPE, CalendarContract.ACCOUNT_TYPE_LOCAL) + .build() + + @Before + fun packTheMonth() { + val calendar = ContentValues().apply { + put(CalendarContract.Calendars.ACCOUNT_NAME, ACCOUNT) + put(CalendarContract.Calendars.ACCOUNT_TYPE, CalendarContract.ACCOUNT_TYPE_LOCAL) + put(CalendarContract.Calendars.NAME, ACCOUNT) + put(CalendarContract.Calendars.CALENDAR_DISPLAY_NAME, ACCOUNT) + put(CalendarContract.Calendars.CALENDAR_COLOR, 0xFF3F51B5.toInt()) + put(CalendarContract.Calendars.CALENDAR_ACCESS_LEVEL, CalendarContract.Calendars.CAL_ACCESS_OWNER) + put(CalendarContract.Calendars.OWNER_ACCOUNT, ACCOUNT) + put(CalendarContract.Calendars.VISIBLE, 1) + put(CalendarContract.Calendars.SYNC_EVENTS, 1) + } + val uri = context.contentResolver.insert(CalendarContract.Calendars.CONTENT_URI.asSyncAdapter(), calendar) + calendarId = ContentUris.parseId(requireNotNull(uri)) + + val zone = ZoneId.systemDefault() + val first = LocalDate.now(zone).withDayOfMonth(1).minusDays(7) + val events = (0 until 49).flatMap { offset -> + val day = first.plusDays(offset.toLong()) + (0 until EVENTS_PER_DAY).map { i -> + val start = day.atTime(8 + i, 0).atZone(zone).toInstant().toEpochMilli() + ContentValues().apply { + put(CalendarContract.Events.CALENDAR_ID, calendarId) + put(CalendarContract.Events.TITLE, "Busy $i") + put(CalendarContract.Events.DTSTART, start) + put(CalendarContract.Events.DTEND, start + 30 * 60_000L) + put(CalendarContract.Events.EVENT_TIMEZONE, zone.id) + } + } + } + context.contentResolver.bulkInsert(CalendarContract.Events.CONTENT_URI.asSyncAdapter(), events.toTypedArray()) + } + + @After + fun removeCalendar() { + if (calendarId < 0) return + context.contentResolver.delete( + ContentUris.withAppendedId(CalendarContract.Calendars.CONTENT_URI, calendarId).asSyncAdapter(), + null, + null, + ) + } + + @Test + fun aPackedMonthRendersWithinGlancesViewIds() = runBlocking { + for (size in listOf(DpSize(250.dp, 250.dp), DpSize(320.dp, 360.dp), DpSize(420.dp, 520.dp))) { + val views = MonthWidget().compose(context, size = size) + var total = 0 + var texts = 0 + instrumentation.runOnMainSync { + val root = views.apply(context, FrameLayout(context)) + fun walk(view: View) { + total += 1 + if (view is TextView) texts += 1 + if (view is ViewGroup) for (i in 0 until view.childCount) walk(view.getChildAt(i)) + } + walk(root) + } + Log.i(TAG, "size=$size views=$total texts=$texts budget=$VIEW_BUDGET") + // The grid itself rendered, not Glance's error layout: every day number is there. + assertThat(texts).isAtLeast(35) + assertThat(total).isAtMost(GLANCE_VIEW_IDS) + } + } + + private companion object { + const val TAG = "MonthWidgetBudget" + const val ACCOUNT = "calendula-widget-budget-test" + const val EVENTS_PER_DAY = 6 + const val GLANCE_VIEW_IDS = 499 + } +} diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/widget/month/MonthGrid.kt b/app/src/main/java/de/jeanlucmakiola/calendula/widget/month/MonthGrid.kt index 3a8915e..53a8815 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/widget/month/MonthGrid.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/widget/month/MonthGrid.kt @@ -1,6 +1,8 @@ package de.jeanlucmakiola.calendula.widget.month +import android.os.Build import de.jeanlucmakiola.calendula.domain.EventInstance +import de.jeanlucmakiola.calendula.ui.month.DaySeating import de.jeanlucmakiola.calendula.ui.month.MonthWeek import de.jeanlucmakiola.calendula.ui.month.seating import de.jeanlucmakiola.calendula.ui.month.spanAt @@ -10,100 +12,133 @@ import kotlinx.datetime.LocalDate internal const val MAX_LANES = 3 /** - * Glance gives each rendering 500 view ids and fails the whole widget past them - * (#372). The grid stays under this, leaving room for Glance's own wrappers. + * View ids one rendering may spend. Glance has 499 and fails the whole widget + * past them (#372); staying well under also keeps a busy month's payload, sent + * once per host size, clear of the binder limit #214 ran into. */ -internal const val VIEW_BUDGET = 440 - -/** Views outside the week rows: the header row with its title and three icons, a spacer and the weekday row. */ -internal const val FIXED_VIEWS = 18 - -/** A bar occupying [cols] columns, or — with a null event — that many blank ones. */ -internal data class LaneCell(val event: EventInstance?, val cols: Int) - -/** A "+[count]" label one column wide, or — with a zero count — a gap [cols] wide. */ -internal data class OverflowCell(val count: Int, val cols: Int) +internal const val VIEW_BUDGET = 400 /** - * Lane [lane] split into bars and the gaps between them, consecutive blank - * columns merged into one gap: the day-open taps come from the strip underneath, - * so a gap needs no per-column view and a mostly-empty lane collapses to a spacer. + * What views cost in Glance 1.2's view ids. A clickable view that isn't a + * Button is wrapped in a Box sized like it, with a ripple Image and the view + * itself filling it. Below Android 12 a view with a fixed width or height is + * inflated into a sizing frame first, which takes a second id. */ -internal fun MonthWeek.laneCells(lane: Int, laneCap: Int): List { - val cells = mutableListOf() +internal class ViewIds(sdkInt: Int = Build.VERSION.SDK_INT) { + val plain = 1 + val sized = if (sdkInt < Build.VERSION_CODES.S) 2 else 1 + val clickablePlain = plain + 2 + 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. + */ + val fixed = plain + plain + 3 * (clickableSized + sized) + clickablePlain + sized + plain + 7 * sized +} + +/** A cell [cols] columns wide holding [item], or a merged gap where [item] is null. */ +internal data class GridCell(val item: T?, val cols: Int) + +/** + * The seven columns as cells and the gaps between them, consecutive empty + * columns merged into one: the day-open taps come from the strip underneath, + * so a gap needs no per-column view. [cellAt] gives what starts at a column + * and how many columns it covers. + */ +internal fun mergeGaps(columns: Int, cellAt: (Int) -> GridCell?): List> { + val cells = mutableListOf>() var gap = 0 var col = 0 - while (col < days.size) { - val seat = seating(col, laneCap).seated.firstOrNull { it.lane == lane } - if (seat == null) { + while (col < columns) { + val cell = cellAt(col) + if (cell == null) { gap += 1 col += 1 continue } - if (gap > 0) cells += LaneCell(null, gap) + if (gap > 0) cells += GridCell(null, gap) gap = 0 - val cols = if (seat.bar) (spanAt(col, lane)?.endCol ?: col) - col + 1 else 1 - cells += LaneCell(seat.event, cols) - col += cols + cells += cell + col += cell.cols } - if (gap > 0) cells += LaneCell(null, gap) + if (gap > 0) cells += GridCell(null, gap) return cells } -/** How many lanes this week fills from the top under [laneCap]; empty lanes below are not drawn. */ -internal fun MonthWeek.usedLanes(laneCap: Int): Int = - days.indices.maxOfOrNull { col -> seating(col, laneCap).seated.maxOfOrNull { it.lane + 1 } ?: 0 } ?: 0 +/** One week of the grid seated with [laneCap] lanes, computed once for both drawing and counting. */ +internal class WeekLayout(val week: MonthWeek, val laneCap: Int) { + private val seating: List = week.days.indices.map { week.seating(it, laneCap) } -/** Events per day that no lane had room for — the "+N" counts, 0 where all fit. */ -internal fun MonthWeek.hiddenPerDay(laneCap: Int): List = - days.indices.map { col -> seating(col, laneCap).hidden.size } + /** Lanes filled from the top; empty lanes below are not drawn. */ + val usedLanes: Int = seating.maxOfOrNull { day -> day.seated.maxOfOrNull { it.lane + 1 } ?: 0 } ?: 0 -/** The "+N" row as labels and merged gaps; empty when nothing is hidden. */ -internal fun overflowCells(hidden: List): List { - if (hidden.none { it > 0 }) return emptyList() - val cells = mutableListOf() - var gap = 0 - hidden.forEach { count -> - if (count == 0) { - gap += 1 - } else { - if (gap > 0) cells += OverflowCell(0, gap) - gap = 0 - cells += OverflowCell(count, 1) + /** Each drawn lane as bars and merged gaps. */ + val lanes: List>> = List(usedLanes) { lane -> + mergeGaps(week.days.size) { col -> + val seat = seating[col].seated.firstOrNull { it.lane == lane } ?: return@mergeGaps null + val endCol = if (seat.bar) week.spanAt(col, lane)?.endCol ?: col else col + GridCell(seat.event, endCol - col + 1) } } - if (gap > 0) cells += OverflowCell(0, gap) - return cells -} -/** The views `WeekRow` emits for this week with [laneCap] lanes, counted the way it draws them. */ -internal fun MonthWeek.viewCount(laneCap: Int, today: LocalDate): Int { - val strips = 1 + days.size - val numbers = 1 + days.size + if (today in days) 2 else 0 - val lanes = (0 until usedLanes(laneCap)).sumOf { lane -> - 2 + laneCells(lane, laneCap).sumOf { if (it.event == null) 1 else 3 } + /** The "+N" row as counts and merged gaps; empty when everything fits. */ + val overflow: List> = seating.map { it.hidden.size }.let { hidden -> + if (hidden.none { it > 0 }) emptyList() + else mergeGaps(hidden.size) { col -> hidden[col].takeIf { it > 0 }?.let { GridCell(it, 1) } } + } + + /** + * The view ids `WeekRow` spends on this week, counted the way it draws it: + * the week Box, the strip Row and its seven sized clickable strips, the + * content Column, the sized day-number row and its sized numbers (today's + * pill adds a sized Box around a plain Text), the sized spacer, each lane's + * Row and sized spacer with sized gaps and bars (a sized Box around a + * filling Box and Text), and the "+N" Row of sized cells. + */ + fun viewIds(today: LocalDate, ids: ViewIds): Int { + val days = week.days.size + val strips = ids.plain + days * ids.clickableSized + val numbers = ids.sized + days * ids.sized + if (today in week.days) ids.sized + ids.plain else 0 + val lanes = lanes.sumOf { cells -> + ids.plain + ids.sized + cells.sumOf { if (it.item == null) ids.sized else ids.sized + 2 * ids.plain } + } + val overflow = if (overflow.isEmpty()) 0 else ids.plain + overflow.size * ids.sized + return ids.plain + strips + ids.plain + numbers + ids.sized + lanes + overflow } - val overflow = overflowCells(hiddenPerDay(laneCap)).let { if (it.isEmpty()) 0 else 1 + it.size } - // The week's Box, its content Column and the spacer under the numbers. - return 3 + strips + numbers + lanes + overflow } /** - * The lanes each week may draw so the grid fits [budget] views: the most - * expensive week gives up its lowest lane first, its events folding into "+N", - * until the whole month fits (#372). + * Seats each week so the whole grid fits [budget] view ids (#372). Each round + * folds the lowest lanes of whichever week saves the most ids per lane given + * up, their events joining "+N", until the month fits. */ -internal fun laneCaps( +internal fun layoutWeeks( weeks: List, today: LocalDate, - budget: Int = VIEW_BUDGET - FIXED_VIEWS, -): List { - val caps = MutableList(weeks.size) { MAX_LANES } - val costs = MutableList(weeks.size) { weeks[it].viewCount(MAX_LANES, today) } + ids: ViewIds = ViewIds(), + budget: Int = VIEW_BUDGET - ids.fixed, +): List { + val layouts = weeks.map { week -> WeekLayout(week, WeekLayout(week, MAX_LANES).usedLanes) }.toMutableList() + val costs = layouts.map { it.viewIds(today, ids) }.toMutableList() while (costs.sum() > budget) { - val busiest = weeks.indices.filter { caps[it] > 0 }.maxByOrNull { costs[it] } ?: break - caps[busiest] -= 1 - costs[busiest] = weeks[busiest].viewCount(caps[busiest], today) + var best: Triple? = null + var bestRate = 0.0 + layouts.forEachIndexed { i, layout -> + for (cap in layout.laneCap - 1 downTo 0) { + val folded = WeekLayout(layout.week, cap) + val cost = folded.viewIds(today, ids) + val rate = (costs[i] - cost).toDouble() / (layout.laneCap - cap) + if (rate > bestRate) { + bestRate = rate + best = Triple(i, folded, cost) + } + } + } + val (i, folded, cost) = best ?: break + layouts[i] = folded + costs[i] = cost } - return caps + return layouts } 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 25a6fae..332726e 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 @@ -123,8 +123,8 @@ class MonthWidget : GlanceAppWidget() { * Exact rather than [SizeMode.Single] or [SizeMode.Responsive], both of which * return early from `GlanceAppWidget.resize` — under those the grid is never * recomposed for a new size and a resized widget clips instead of reflowing. - * Exact costs one serialised copy per host size, which is affordable now that - * a rendering is ~190 views rather than ~740. + * Exact costs one serialised copy per host size, which is why a rendering is + * held to [VIEW_BUDGET] view ids, folding lanes into "+N" on a busy month. */ override val sizeMode = SizeMode.Exact @@ -223,15 +223,13 @@ private fun MonthWidgetBody(source: MonthWidgetSource, index: Int) { // Column width from the live widget size, minus our H padding. val colW = (LocalSize.current.width - GRID_HPADDING * 2) / 7 val weeks = layoutMonthWeeks(ym, source.weekStart, source.instances, zone) - val caps = laneCaps(weeks, source.today) MonthHeader(label = monthLabel(ym, source.today.year)) Spacer(GlanceModifier.height(2.dp)) WeekdayHeader(weekStart = source.weekStart, colW = colW) - weeks.forEachIndexed { i, week -> + layoutWeeks(weeks, source.today).forEach { layout -> WeekRow( - week = week, - laneCap = caps[i], + layout = layout, currentMonth = ym.month, today = source.today, soften = source.soften, @@ -340,8 +338,7 @@ private fun weekdayNarrowNames(weekStart: DayOfWeek): List { @Composable private fun WeekRow( - week: MonthWeek, - laneCap: Int, + layout: WeekLayout, currentMonth: Month, today: LocalDate, soften: Boolean, @@ -349,7 +346,7 @@ private fun WeekRow( modifier: GlanceModifier, ) { val context = LocalContext.current - val overflow = overflowCells(week.hiddenPerDay(laneCap)) + val week = layout.week Box(modifier = modifier.fillMaxWidth()) { // Day-open targets, one full-height strip per column, sitting *under* the // content: a tap anywhere in a day still opens it, as in the app, without @@ -387,12 +384,12 @@ 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. // The grid's view budget caps the lanes per week (#372); what doesn't - // fit folds into the "+N" row. Mirrored one to one by viewCount. - repeat(week.usedLanes(laneCap)) { lane -> - LaneRow(cells = week.laneCells(lane, laneCap), soften = soften, colW = colW) + // fit folds into the "+N" row. Counted the same way by WeekLayout.viewIds. + layout.lanes.forEach { cells -> + LaneRow(cells = cells, soften = soften, colW = colW) Spacer(GlanceModifier.height(1.dp)) } - if (overflow.isNotEmpty()) OverflowRow(cells = overflow, colW = colW) + if (layout.overflow.isNotEmpty()) OverflowRow(cells = layout.overflow, colW = colW) } } } @@ -440,14 +437,15 @@ private fun DayNumber(date: LocalDate, isToday: Boolean, inMonth: Boolean, colW: } @Composable -private fun LaneRow(cells: List, soften: Boolean, colW: Dp) { +private fun LaneRow(cells: List>, soften: Boolean, colW: Dp) { Row(modifier = GlanceModifier.fillMaxWidth()) { cells.forEach { cell -> - if (cell.event == null) { + val event = cell.item + if (event == null) { Spacer(GlanceModifier.width(colW * cell.cols).height(LANE_HEIGHT)) } else { SpanBar( - event = cell.event, + event = event, soften = soften, width = colW * cell.cols, ) @@ -491,14 +489,14 @@ private fun SpanBar(event: EventInstance, soften: Boolean, width: Dp) { /** The "+N" row, drawn only for weeks that hide something, gaps merged as in a lane. */ @Composable -private fun OverflowRow(cells: List, colW: Dp) { +private fun OverflowRow(cells: List>, colW: Dp) { Row(modifier = GlanceModifier.fillMaxWidth()) { cells.forEach { cell -> - if (cell.count == 0) { + if (cell.item == null) { Spacer(GlanceModifier.width(colW * cell.cols).height(LANE_HEIGHT)) } else { Text( - text = "+${cell.count}", + text = "+${cell.item}", maxLines = 1, style = TextStyle(color = GlanceTheme.colors.onSurfaceVariant, fontSize = 9.sp), modifier = GlanceModifier.width(colW).padding(start = 3.dp), diff --git a/app/src/test/java/de/jeanlucmakiola/calendula/widget/month/MonthGridTest.kt b/app/src/test/java/de/jeanlucmakiola/calendula/widget/month/MonthGridTest.kt index f2751e5..4af9e7a 100644 --- a/app/src/test/java/de/jeanlucmakiola/calendula/widget/month/MonthGridTest.kt +++ b/app/src/test/java/de/jeanlucmakiola/calendula/widget/month/MonthGridTest.kt @@ -1,7 +1,10 @@ package de.jeanlucmakiola.calendula.widget.month import com.google.common.truth.Truth.assertThat +import de.jeanlucmakiola.calendula.allDayEvent +import de.jeanlucmakiola.calendula.domain.EventInstance import de.jeanlucmakiola.calendula.timedEvent +import de.jeanlucmakiola.calendula.ui.month.MonthWeek import de.jeanlucmakiola.calendula.ui.month.layoutMonthWeeks import kotlinx.datetime.DateTimeUnit import kotlinx.datetime.DayOfWeek @@ -19,60 +22,88 @@ class MonthGridTest { private val jul26 = YearMonth(2026, Month.JULY) private val today = LocalDate(2026, 7, 15) + /** Monday Jul 6 to Sunday Jul 12. */ + private val monday = LocalDate(2026, 7, 6) + + private val android16 = ViewIds(sdkInt = 36) + private val android10 = ViewIds(sdkInt = 29) + + private fun weeks(events: List): List = + layoutMonthWeeks(jul26, DayOfWeek.MONDAY, events, zone) + /** July 2026 with [perDay] timed events on every day of the grid. */ - private fun weeks(perDay: Int): List { + private fun busyWeeks(perDay: Int): List { val first = LocalDate(2026, 6, 29) var id = 0L - val events = (0 until 42).flatMap { offset -> - val day = first.plus(offset, DateTimeUnit.DAY) - (1..perDay).map { hour -> - timedEvent(day, hour, zone = zone, id = ++id, title = "E$id", color = 0xFFF44336.toInt()) - } + return weeks( + (0 until 42).flatMap { offset -> + val day = first.plus(offset, DateTimeUnit.DAY) + (1..perDay).map { hour -> timedEvent(day, hour, zone = zone, id = ++id, title = "E$id") } + }, + ) + } + + private fun List.cost(ids: ViewIds) = sumOf { it.viewIds(today, ids) } + + @Test + fun `a quiet month keeps every lane it uses`() { + val layouts = layoutWeeks(busyWeeks(perDay = 1), today, android16) + assertThat(layouts.map { it.laneCap }.distinct()).containsExactly(1) + assertThat(layouts.all { it.overflow.isEmpty() }).isTrue() + } + + @Test + fun `a full month folds lanes until it fits, on every Android version`() { + for (ids in listOf(android16, android10)) { + val weeks = busyWeeks(perDay = 5) + assertThat(weeks.map { WeekLayout(it, MAX_LANES) }.cost(ids)).isGreaterThan(VIEW_BUDGET - ids.fixed) + + val layouts = layoutWeeks(weeks, today, ids) + assertThat(layouts.cost(ids)).isAtMost(VIEW_BUDGET - ids.fixed) + assertThat(layouts.any { it.laneCap < MAX_LANES }).isTrue() } - return layoutMonthWeeks(jul26, DayOfWeek.MONDAY, events, zone) } @Test - fun `a quiet month keeps every lane`() { - val weeks = weeks(perDay = 1) - assertThat(laneCaps(weeks, today)).containsExactlyElementsIn(List(weeks.size) { MAX_LANES }) - } - - @Test - fun `a full month folds lanes until it fits the budget`() { - val weeks = weeks(perDay = 5) - val fullCost = weeks.sumOf { it.viewCount(MAX_LANES, today) } - assertThat(fullCost).isGreaterThan(VIEW_BUDGET - FIXED_VIEWS) - - val caps = laneCaps(weeks, today) - val cost = weeks.indices.sumOf { weeks[it].viewCount(caps[it], today) } - assertThat(cost).isAtMost(VIEW_BUDGET - FIXED_VIEWS) - assertThat(caps.any { it < MAX_LANES }).isTrue() + fun `older Android folds more, since a sized view costs it two ids`() { + val weeks = busyWeeks(perDay = 3) + val newer = layoutWeeks(weeks, today, android16).sumOf { it.laneCap } + val older = layoutWeeks(weeks, today, android10).sumOf { it.laneCap } + assertThat(older).isLessThan(newer) } @Test fun `folded events are counted in the overflow`() { - val week = weeks(perDay = 5)[1] - val hidden = week.hiddenPerDay(laneCap = 1) - assertThat(hidden).containsExactlyElementsIn(List(7) { 4 }) - assertThat(week.usedLanes(laneCap = 1)).isEqualTo(1) + val layout = WeekLayout(busyWeeks(perDay = 5)[1], laneCap = 1) + assertThat(layout.usedLanes).isEqualTo(1) + assertThat(layout.overflow).containsExactly(GridCell(4, 1), GridCell(4, 1), GridCell(4, 1), + GridCell(4, 1), GridCell(4, 1), GridCell(4, 1), GridCell(4, 1)).inOrder() } @Test - fun `a lane of single-day events is one cell per day`() { - val cells = weeks(perDay = 1)[1].laneCells(lane = 0, laneCap = MAX_LANES) - assertThat(cells).hasSize(7) - assertThat(cells.all { it.event != null && it.cols == 1 }).isTrue() + fun `a multi-day bar is one cell as wide as its days`() { + val bar = allDayEvent(monday.plus(1, DateTimeUnit.DAY), monday.plus(3, DateTimeUnit.DAY), id = 1) + val layout = WeekLayout(weeks(listOf(bar))[1], MAX_LANES) + assertThat(layout.lanes).hasSize(1) + assertThat(layout.lanes[0]).containsExactly(GridCell(null, 1), GridCell(bar, 3), GridCell(null, 3)).inOrder() } @Test - fun `overflow gaps are merged`() { - assertThat(overflowCells(listOf(0, 0, 2, 0, 0, 0, 1))).containsExactly( - OverflowCell(0, 2), - OverflowCell(2, 1), - OverflowCell(0, 3), - OverflowCell(1, 1), - ).inOrder() - assertThat(overflowCells(List(7) { 0 })).isEmpty() + fun `a bar on a folded lane moves into the overflow of each day it covers`() { + val top = allDayEvent(monday, monday.plus(6, DateTimeUnit.DAY), id = 1) + val lower = allDayEvent(monday.plus(2, DateTimeUnit.DAY), monday.plus(3, DateTimeUnit.DAY), id = 2) + val layout = WeekLayout(weeks(listOf(top, lower))[1], laneCap = 1) + assertThat(layout.lanes).hasSize(1) + assertThat(layout.lanes[0]).containsExactly(GridCell(top, 7)) + assertThat(layout.overflow).containsExactly(GridCell(null, 2), GridCell(1, 1), GridCell(1, 1), GridCell(null, 3)) + .inOrder() + } + + @Test + fun `gaps are merged`() { + val counts = listOf(0, 0, 2, 0, 0, 0, 1) + val cells = mergeGaps(counts.size) { col -> counts[col].takeIf { it > 0 }?.let { GridCell(it, 1) } } + assertThat(cells).containsExactly(GridCell(null, 2), GridCell(2, 1), GridCell(null, 3), GridCell(1, 1)) + .inOrder() } } From 04a677dc1012dbd5e5a0c38fcb369f1a8d1b7cfe Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Fri, 23 Oct 2026 11:12:18 +0200 Subject: [PATCH 08/24] Per-widget config screen: background, theme and agenda options (#292) --- app/src/main/AndroidManifest.xml | 10 + .../calendula/data/prefs/SettingsPrefs.kt | 17 +- .../calendula/ui/settings/SettingsScreen.kt | 2 +- .../calendula/ui/settings/SettingsUiState.kt | 7 +- .../ui/settings/SettingsViewModel.kt | 8 - .../calendula/ui/settings/WidgetSettings.kt | 134 ++++--- .../calendula/widget/WidgetConfig.kt | 88 +++++ .../calendula/widget/WidgetData.kt | 77 ++-- .../calendula/widget/WidgetRedraw.kt | 31 +- .../calendula/widget/WidgetTheme.kt | 17 +- .../calendula/widget/agenda/AgendaWidget.kt | 134 +++++-- .../widget/agenda/AgendaWidgetReceiver.kt | 6 + .../calendula/widget/config/PlacedWidgets.kt | 31 ++ .../widget/config/WidgetConfigActivity.kt | 130 +++++++ .../widget/config/WidgetConfigScreen.kt | 328 ++++++++++++++++++ .../widget/config/WidgetConfigViewModel.kt | 242 +++++++++++++ .../calendula/widget/month/MonthGrid.kt | 9 +- .../calendula/widget/month/MonthWidget.kt | 65 ++-- .../widget/month/MonthWidgetReceiver.kt | 6 + app/src/main/res/values/strings.xml | 24 +- app/src/main/res/values/themes.xml | 6 + .../main/res/xml/appwidget_info_agenda.xml | 2 + app/src/main/res/xml/appwidget_info_month.xml | 2 + .../calendula/data/prefs/SettingsPrefsTest.kt | 5 +- floret-kit | 2 +- 25 files changed, 1191 insertions(+), 192 deletions(-) create mode 100644 app/src/main/java/de/jeanlucmakiola/calendula/widget/WidgetConfig.kt create mode 100644 app/src/main/java/de/jeanlucmakiola/calendula/widget/config/PlacedWidgets.kt create mode 100644 app/src/main/java/de/jeanlucmakiola/calendula/widget/config/WidgetConfigActivity.kt create mode 100644 app/src/main/java/de/jeanlucmakiola/calendula/widget/config/WidgetConfigScreen.kt create mode 100644 app/src/main/java/de/jeanlucmakiola/calendula/widget/config/WidgetConfigViewModel.kt diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 7dc63c5..44143a5 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -318,6 +318,16 @@ android:exported="false" /> + + + + + = prefs.flow(SettingsKeys.dragToReschedule) val tapOpensEditor: Flow = prefs.flow(SettingsKeys.tapOpensEditor) val agendaScreenRange: Flow = prefs.flow(SettingsKeys.agendaScreenRange) - val agendaWidgetRange: Flow = prefs.flow(SettingsKeys.agendaWidgetRange) - val widgetSize: Flow = prefs.flow(SettingsKeys.widgetSize) val agendaShowRangeBar: Flow = prefs.flow(SettingsKeys.agendaShowRangeBar) val agendaShowToday: Flow = prefs.flow(SettingsKeys.agendaShowToday) val defaultView: Flow = prefs.flow(SettingsKeys.defaultView) @@ -231,8 +229,6 @@ class SettingsPrefs @Inject constructor( suspend fun setDragToReschedule(enabled: Boolean) = prefs.set(SettingsKeys.dragToReschedule, enabled) suspend fun setTapOpensEditor(enabled: Boolean) = prefs.set(SettingsKeys.tapOpensEditor, enabled) suspend fun setAgendaScreenRange(range: AgendaRange) = prefs.set(SettingsKeys.agendaScreenRange, range) - suspend fun setAgendaWidgetRange(range: AgendaRange) = prefs.set(SettingsKeys.agendaWidgetRange, range) - suspend fun setWidgetSize(size: WidgetSize) = prefs.set(SettingsKeys.widgetSize, size) suspend fun setAgendaShowRangeBar(enabled: Boolean) = prefs.set(SettingsKeys.agendaShowRangeBar, enabled) suspend fun setAgendaShowToday(enabled: Boolean) = prefs.set(SettingsKeys.agendaShowToday, enabled) suspend fun setDefaultView(view: CalendarView) = prefs.set(SettingsKeys.defaultView, view) @@ -497,13 +493,16 @@ object SettingsKeys { /** How far ahead the in-app Agenda screen shows events (v2.11). */ val agendaScreenRange: Pref = agendaRangePref("agenda_screen_range") - /** How far ahead the agenda **widget** shows events (v2.11). */ + /** + * How far ahead the agenda **widget** shows events (v2.11). Read-only since + * widgets keep their own options (#292): the default for a widget that has + * none yet. + */ val agendaWidgetRange: Pref = agendaRangePref("agenda_widget_range") /** * The size step the agenda widget draws its text at (#51). [WidgetSize.SMALL] - * reproduces its original metrics. The month widget takes no size setting — - * it divides the width it is given by seven (#103). + * reproduces its original metrics. Read-only like [agendaWidgetRange] (#292). */ val widgetSize: Pref = enumPref("widget_size", WidgetSize.SMALL) @@ -511,8 +510,8 @@ object SettingsKeys { val agendaShowRangeBar: Pref = booleanPref("agenda_show_range_bar", true) /** - * Whether the agenda (screen and widget) always anchors today at the top with - * a "nothing left today" placeholder (issue #35). + * Whether the agenda screen always anchors today at the top with a "nothing + * left today" placeholder (issue #35), and the default for a widget (#292). */ val agendaShowToday: Pref = booleanPref("agenda_show_today", true) diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/SettingsScreen.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/SettingsScreen.kt index 756dbe0..2ffe433 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/SettingsScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/SettingsScreen.kt @@ -155,7 +155,7 @@ fun SettingsScreen( enter = slideInHorizontally(slideSpec) { it } + fadeIn(), exit = overlayExit, ) { - WidgetsScreen(state = state, viewModel = viewModel, onBack = { section = null }, visible = section == SettingsSection.Widgets) + WidgetsScreen(onBack = { section = null }, visible = section == SettingsSection.Widgets) } AnimatedVisibility( visible = section == SettingsSection.Licences, diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/SettingsUiState.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/SettingsUiState.kt index 5d4dcf1..53c14bb 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/SettingsUiState.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/SettingsUiState.kt @@ -16,7 +16,6 @@ import de.jeanlucmakiola.calendula.ui.common.QuickSwitchConfig import de.jeanlucmakiola.calendula.ui.common.TimelineHours import de.jeanlucmakiola.calendula.ui.common.TimelineScale import de.jeanlucmakiola.calendula.ui.month.MonthViewStyle -import de.jeanlucmakiola.calendula.widget.WidgetSize /** * Settings screen state (M4). Persisted preferences are instant to read, so @@ -51,14 +50,10 @@ data class SettingsUiState( val tapOpensEditor: Boolean = false, /** How far ahead the in-app Agenda screen shows events (v2.11). */ val agendaScreenRange: AgendaRange = AgendaRange.Month, - /** How far ahead the agenda widget shows events (v2.11). */ - val agendaWidgetRange: AgendaRange = AgendaRange.Month, - /** Whether the agenda (screen + widget) always anchors today at the top (#35). */ + /** Whether the agenda screen always anchors today at the top (#35); widgets have their own (#292). */ val agendaShowToday: Boolean = true, /** Whether the agenda shows its top range bar — header + switcher (v2.11). */ val agendaShowRangeBar: Boolean = true, - /** The size step the agenda widget draws its text at (#51). */ - val widgetSize: WidgetSize = WidgetSize.SMALL, /** The calendar view the app opens on, and the home of the view back stack (M1). */ val defaultView: CalendarView = CalendarView.Week, /** Which views the top-bar quick-switch button cycles through, and their order (#24). */ diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/SettingsViewModel.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/SettingsViewModel.kt index 1d4b93a..8326032 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/SettingsViewModel.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/SettingsViewModel.kt @@ -45,7 +45,6 @@ import de.jeanlucmakiola.calendula.ui.common.TimelineScale import de.jeanlucmakiola.calendula.ui.month.MonthViewStyle import de.jeanlucmakiola.calendula.ui.theme.AppFontSettings import de.jeanlucmakiola.calendula.ui.theme.FONT_CUSTOM_TOKEN -import de.jeanlucmakiola.calendula.widget.WidgetSize import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableSharedFlow @@ -106,10 +105,8 @@ class SettingsViewModel @Inject constructor( dragToReschedule = this[SettingsKeys.dragToReschedule], tapOpensEditor = this[SettingsKeys.tapOpensEditor], agendaScreenRange = this[SettingsKeys.agendaScreenRange], - agendaWidgetRange = this[SettingsKeys.agendaWidgetRange], agendaShowToday = this[SettingsKeys.agendaShowToday], agendaShowRangeBar = this[SettingsKeys.agendaShowRangeBar], - widgetSize = this[SettingsKeys.widgetSize], defaultView = this[SettingsKeys.defaultView], quickSwitchConfig = this[SettingsKeys.quickSwitchConfig], monthViewStyle = this[SettingsKeys.monthViewStyle], @@ -283,13 +280,8 @@ class SettingsViewModel @Inject constructor( fun setAgendaScreenRange(range: AgendaRange) = save { setAgendaScreenRange(range) } - fun setAgendaWidgetRange(range: AgendaRange) = save { setAgendaWidgetRange(range) } - fun setAgendaShowRangeBar(enabled: Boolean) = save { setAgendaShowRangeBar(enabled) } - /** The size the agenda widget draws its text at (#51); the month widget sizes itself (#103). */ - fun setWidgetSize(size: WidgetSize) = save { setWidgetSize(size) } - fun setAgendaShowToday(enabled: Boolean) = save { setAgendaShowToday(enabled) } fun setTimeFormat(pref: TimeFormatPref) = save { setTimeFormat(pref) } diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/WidgetSettings.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/WidgetSettings.kt index 456efbe..aad1d35 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/WidgetSettings.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/WidgetSettings.kt @@ -10,7 +10,9 @@ import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.height import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.produceState +import androidx.lifecycle.compose.LifecycleResumeEffect import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier @@ -18,56 +20,47 @@ import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import de.jeanlucmakiola.calendula.R -import de.jeanlucmakiola.calendula.data.prefs.resolveFirstDay import de.jeanlucmakiola.calendula.qs.NewEventTileService -import de.jeanlucmakiola.calendula.ui.common.AgendaRangePicker -import de.jeanlucmakiola.calendula.ui.common.agendaRangeLabel -import de.jeanlucmakiola.calendula.widget.WidgetSize -import de.jeanlucmakiola.calendula.widget.agenda.metricsFor import de.jeanlucmakiola.floret.components.CollapsingScaffold import de.jeanlucmakiola.floret.components.GroupedHint import de.jeanlucmakiola.floret.components.GroupedRow -import de.jeanlucmakiola.floret.components.OptionPicker import de.jeanlucmakiola.floret.components.Position -import de.jeanlucmakiola.floret.locale.currentLocale -import kotlin.math.roundToInt +import de.jeanlucmakiola.floret.components.GroupedSectionHeader +import de.jeanlucmakiola.floret.components.positionOf +import de.jeanlucmakiola.calendula.widget.config.PlacedWidget +import de.jeanlucmakiola.calendula.widget.config.WidgetConfigActivity +import de.jeanlucmakiola.calendula.widget.config.WidgetKind +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import de.jeanlucmakiola.calendula.widget.config.backgroundLabel +import de.jeanlucmakiola.calendula.widget.config.label +import de.jeanlucmakiola.calendula.widget.config.placedWidgets +import de.jeanlucmakiola.calendula.widget.config.themeLabel /** - * Widgets & tiles (#69): the home-screen widgets' settings and the Quick - * Settings tile shortcut. The agenda widget keeps its own range and text size, - * separate from the Agenda screen's range in [ViewsScreen]. + * Widgets & tiles (#69): the placed home-screen widgets, each opening its own + * config screen (#292), and the Quick Settings tile shortcut. */ @Composable internal fun WidgetsScreen( - state: SettingsUiState, - viewModel: SettingsViewModel, onBack: () -> Unit, visible: Boolean = true, ) { - var showAgendaWidgetRange by remember { mutableStateOf(false) } - var showWidgetSize by remember { mutableStateOf(false) } - CollapsingScaffold( title = stringResource(R.string.settings_section_widgets), onBack = onBack, predictiveBack = true, visible = visible, ) { - GroupedHint(stringResource(R.string.settings_widgets_hint)) - Spacer(Modifier.height(8.dp)) - - GroupedRow( - title = stringResource(R.string.settings_agenda_widget_range), - summary = agendaRangeLabel(state.agendaWidgetRange), - position = Position.Top, - onClick = { showAgendaWidgetRange = true }, - ) - GroupedRow( - title = stringResource(R.string.settings_widget_size), - summary = widgetSizeLabel(state.widgetSize), - position = Position.Bottom, - onClick = { showWidgetSize = true }, + GroupedHint( + stringResource( + // The launcher's "Edit widget" action only exists from Android 12. + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) R.string.settings_widgets_hint + else R.string.settings_widgets_hint_legacy, + ), ) + GroupedSectionHeader(stringResource(R.string.settings_widgets_placed)) + PlacedWidgetRows() // The add-tile prompt is API 33+; below that the tile is still // addable from the QS editor, so the row just doesn't appear. @@ -76,30 +69,44 @@ internal fun WidgetsScreen( QuickSettingsTileRow() } } +} - if (showAgendaWidgetRange) { - AgendaRangePicker( - title = stringResource(R.string.settings_agenda_widget_range), - description = stringResource(R.string.settings_agenda_widget_range_hint), - selected = state.agendaWidgetRange, - weekStart = state.weekStart.resolveFirstDay(currentLocale()), - onSelect = viewModel::setAgendaWidgetRange, - onDismiss = { showAgendaWidgetRange = false }, - ) +@Composable +private fun PlacedWidgetRows() { + val context = LocalContext.current + // Read on every resume, the first included, so a widget added, removed or + // edited meanwhile shows up. + var refresh by remember { mutableIntStateOf(0) } + LifecycleResumeEffect(Unit) { + refresh++ + onPauseOrDispose {} } - if (showWidgetSize) { - OptionPicker( - title = stringResource(R.string.settings_widget_size), - header = { GroupedHint(stringResource(R.string.settings_widget_size_hint), verticalPadding = 8.dp) }, - predictiveBack = true, - options = WidgetSize.entries, - selected = state.widgetSize, - label = { widgetSizeLabel(it) }, - // No live preview: the widget is Glance/RemoteViews, so a Compose - // mock-up would be a second implementation free to drift. - summary = { widgetSizeSummary(it) }, - onSelect = viewModel::setWidgetSize, - onDismiss = { showWidgetSize = false }, + val widgets by produceState?>(initialValue = null, refresh) { + if (refresh > 0) value = withContext(Dispatchers.IO) { context.placedWidgets() } + } + val placed = widgets ?: return + if (placed.isEmpty()) { + GroupedRow( + title = stringResource(R.string.settings_widgets_none), + position = Position.Alone, + dimmed = true, + ) + return + } + val kindCounts = placed.groupingBy { it.kind }.eachCount() + val kindSeen = mutableMapOf() + placed.forEachIndexed { index, widget -> + val number = kindSeen.merge(widget.kind, 1, Int::plus)!! + val label = stringResource(widget.kind.label) + GroupedRow( + title = if (kindCounts.getValue(widget.kind) > 1) stringResource(R.string.widget_numbered, label, number) else label, + summary = stringResource( + R.string.widget_appearance_summary, + backgroundLabel(widget.appearance.background), + themeLabel(widget.appearance.theme), + ), + position = positionOf(index, placed.size), + onClick = { context.startActivity(WidgetConfigActivity.intent(context, widget.appWidgetId)) }, ) } } @@ -131,24 +138,3 @@ private fun requestAddQsTile(context: Context) { context.mainExecutor, ) { /* result code unused — the system surfaces its own feedback */ } } - -/** - * What a size step does, as a percentage of the smallest one's event text. - * Derived from the widget's own [metricsFor] table rather than hardcoded. - */ -@Composable -private fun widgetSizeSummary(size: WidgetSize): String { - val base = metricsFor(WidgetSize.SMALL).eventTitle.value - val percent = (metricsFor(size).eventTitle.value / base * 100f).roundToInt() - return stringResource(R.string.settings_widget_size_summary, percent) -} - -@Composable -private fun widgetSizeLabel(size: WidgetSize): String = stringResource( - when (size) { - WidgetSize.SMALL -> R.string.settings_widget_size_small - WidgetSize.MEDIUM -> R.string.settings_widget_size_medium - WidgetSize.LARGE -> R.string.settings_widget_size_large - WidgetSize.EXTRA_LARGE -> R.string.settings_widget_size_extra_large - }, -) diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/widget/WidgetConfig.kt b/app/src/main/java/de/jeanlucmakiola/calendula/widget/WidgetConfig.kt new file mode 100644 index 0000000..69ac74d --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/calendula/widget/WidgetConfig.kt @@ -0,0 +1,88 @@ +package de.jeanlucmakiola.calendula.widget + +import android.content.Context +import androidx.datastore.preferences.core.MutablePreferences +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.core.booleanPreferencesKey +import androidx.datastore.preferences.core.stringPreferencesKey +import androidx.glance.GlanceId +import androidx.glance.appwidget.state.getAppWidgetState +import androidx.glance.state.PreferencesGlanceStateDefinition +import de.jeanlucmakiola.calendula.data.prefs.PastEventDisplay +import de.jeanlucmakiola.calendula.ui.agenda.AgendaRange +import de.jeanlucmakiola.calendula.ui.agenda.parseAgendaRange +import de.jeanlucmakiola.calendula.ui.agenda.storageValue +import de.jeanlucmakiola.floret.glance.WidgetBackground +import de.jeanlucmakiola.floret.glance.WidgetThemeMode +import de.jeanlucmakiola.floret.prefs.ThemeMode +import de.jeanlucmakiola.floret.prefs.toEnum + +/** A widget's own light/dark choice (#292); [FOLLOW_APP] tracks the app's theme setting. */ +enum class WidgetTheme { FOLLOW_APP, LIGHT, DARK } + +/** How a widget looks, set per placed widget in its config screen (#292). */ +data class WidgetAppearance( + val background: WidgetBackground = WidgetBackground.TINTED, + val theme: WidgetTheme = WidgetTheme.FOLLOW_APP, +) + +/** What the agenda widget lists, per placed widget. */ +data class AgendaWidgetOptions( + val range: AgendaRange, + val textSize: WidgetSize, + val showToday: Boolean, + val pastDisplay: PastEventDisplay, +) + +internal fun WidgetTheme.resolve(app: ThemeMode): WidgetThemeMode = when (this) { + WidgetTheme.LIGHT -> WidgetThemeMode.LIGHT + WidgetTheme.DARK -> WidgetThemeMode.DARK + WidgetTheme.FOLLOW_APP -> when (app) { + ThemeMode.SYSTEM -> WidgetThemeMode.SYSTEM + ThemeMode.LIGHT -> WidgetThemeMode.LIGHT + ThemeMode.DARK, ThemeMode.BLACK -> WidgetThemeMode.DARK + } +} + +private val BackgroundKey = stringPreferencesKey("cfg_background") +private val ThemeKey = stringPreferencesKey("cfg_theme") +private val AgendaRangeKey = stringPreferencesKey("cfg_agenda_range") +private val TextSizeKey = stringPreferencesKey("cfg_text_size") +private val ShowTodayKey = booleanPreferencesKey("cfg_show_today") +private val PastDisplayKey = stringPreferencesKey("cfg_past_display") + +internal val Preferences.widgetAppearance: WidgetAppearance + get() = WidgetAppearance( + background = this[BackgroundKey].toEnum(WidgetBackground.TINTED), + theme = this[ThemeKey].toEnum(WidgetTheme.FOLLOW_APP), + ) + +/** + * This widget's agenda options. A widget placed before per-widget options + * existed has none stored, and keeps following the app-wide values in + * [defaults] until it is configured. + */ +internal fun Preferences.agendaWidgetOptions(defaults: AgendaWidgetOptions) = AgendaWidgetOptions( + range = parseAgendaRange(this[AgendaRangeKey], defaults.range), + textSize = this[TextSizeKey].toEnum(defaults.textSize), + showToday = this[ShowTodayKey] ?: defaults.showToday, + pastDisplay = this[PastDisplayKey].toEnum(defaults.pastDisplay), +) + +/** Whether this widget has agenda options of its own, rather than following the app's. */ +internal val Preferences.hasAgendaOptions: Boolean get() = this[AgendaRangeKey] != null + +internal fun MutablePreferences.putAppearance(appearance: WidgetAppearance) { + this[BackgroundKey] = appearance.background.name + this[ThemeKey] = appearance.theme.name +} + +internal fun MutablePreferences.putAgendaOptions(options: AgendaWidgetOptions) { + this[AgendaRangeKey] = options.range.storageValue() + this[TextSizeKey] = options.textSize.name + this[ShowTodayKey] = options.showToday + this[PastDisplayKey] = options.pastDisplay.name +} + +internal suspend fun Context.widgetState(id: GlanceId): Preferences = + getAppWidgetState(this, PreferencesGlanceStateDefinition, id) diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/widget/WidgetData.kt b/app/src/main/java/de/jeanlucmakiola/calendula/widget/WidgetData.kt index 5b5f4a9..dca5a54 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/widget/WidgetData.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/widget/WidgetData.kt @@ -18,6 +18,7 @@ import de.jeanlucmakiola.calendula.ui.agenda.AgendaRange import de.jeanlucmakiola.calendula.ui.agenda.agendaRange import de.jeanlucmakiola.calendula.ui.agenda.dayCount import de.jeanlucmakiola.calendula.ui.agenda.groupAgendaDays +import de.jeanlucmakiola.floret.prefs.ThemeMode import de.jeanlucmakiola.floret.prefs.get import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.distinctUntilChanged @@ -46,26 +47,46 @@ import kotlin.time.Instant */ internal const val MONTH_WIDGET_PAD_MONTHS = 3 -/** Every setting the widgets draw with: what the loads read, and what [WidgetRefreshCoordinator] watches. */ +/** + * Every app setting the widgets draw with: what the loads read, and what + * [WidgetRefreshCoordinator] watches. The agenda fields are only the defaults + * for a widget that has no options of its own yet. + */ internal data class WidgetSettings( val soften: Boolean, val weekStart: WeekStartPref, val timeFormat: TimeFormatPref, + val themeMode: ThemeMode, val pastDisplay: PastEventDisplay, val agendaRange: AgendaRange, val widgetSize: WidgetSize, val showToday: Boolean, ) { - /** The part the month widget draws with; the rest is agenda-only. */ - val month: Pair get() = soften to weekStart + /** What every widget draws with; the agenda defaults aside. */ + val shared: SharedWidgetSettings get() = SharedWidgetSettings(soften, weekStart, timeFormat, themeMode) + + val agendaDefaults: AgendaWidgetOptions + get() = AgendaWidgetOptions(agendaRange, widgetSize, showToday, pastDisplay) } +/** The [WidgetSettings] a configured widget still takes from the app. */ +internal data class SharedWidgetSettings( + val soften: Boolean, + val weekStart: WeekStartPref, + val timeFormat: TimeFormatPref, + val themeMode: ThemeMode, +) + +/** A widget's loaded [content], with the app theme a [WidgetTheme.FOLLOW_APP] widget resolves against. */ +internal data class Themed(val content: T, val appTheme: ThemeMode) + internal val SettingsPrefs.widgetSettings: Flow get() = data.map { WidgetSettings( soften = it[SettingsKeys.softenCalendarColors], weekStart = it[SettingsKeys.weekStart], timeFormat = it[SettingsKeys.timeFormat], + themeMode = it[SettingsKeys.themeMode], pastDisplay = it[SettingsKeys.pastEventDisplay], agendaRange = it[SettingsKeys.agendaWidgetRange], widgetSize = it[SettingsKeys.widgetSize], @@ -97,7 +118,7 @@ sealed interface AgendaWidgetData { val pastDisplay: PastEventDisplay, /** "Always show today" (#35): anchor today at the top even with no events left. */ val showToday: Boolean, - val widgetSize: WidgetSize, + val textSize: WidgetSize, /** Snapshot instant the data was read at, for "has this event ended?" tests. */ val now: Instant, ) : AgendaWidgetData @@ -121,19 +142,28 @@ sealed interface MonthWidgetSource { ) : MonthWidgetSource } +/** [loadAgendaWidgetData] with agenda widget [id]'s own options. */ +internal suspend fun Context.loadAgendaWidget(id: GlanceId): Themed { + val settings = widgetEntryPoint().settingsPrefs().widgetSettings.first() + val options = widgetState(id).agendaWidgetOptions(settings.agendaDefaults) + return Themed(loadAgendaWidgetData(settings, options), settings.themeMode) +} + /** * One-shot read of the upcoming agenda for the widget. Reuses the app's * [agendaRange] window and [groupAgendaDays] grouping, and the repository's * [first]-emitted snapshot already has hidden calendars filtered out. */ -internal suspend fun Context.loadAgendaWidgetData(): AgendaWidgetData { +internal suspend fun Context.loadAgendaWidgetData( + settings: WidgetSettings, + options: AgendaWidgetOptions, +): AgendaWidgetData { if (!hasCalendarPermission()) return AgendaWidgetData.NeedsPermission val zone = systemZone() val anchor = today(zone) val ep = widgetEntryPoint() - val settings = ep.settingsPrefs().widgetSettings.first() val weekStart = settings.weekStart.resolveFirstDay(Locale.getDefault()) - val dayCount = settings.agendaRange.dayCount(anchor, weekStart) + val dayCount = options.range.dayCount(anchor, weekStart) val window = agendaRange(anchor, dayCount - 1, zone) val windowEnd = anchor.plus(dayCount - 1, DateTimeUnit.DAY) val instances = ep.calendarRepository().instances(window).first() @@ -142,9 +172,9 @@ internal suspend fun Context.loadAgendaWidgetData(): AgendaWidgetData { days = groupAgendaDays(anchor, windowEnd, instances, zone), is24Hour = settings.timeFormat.is24Hour(android.text.format.DateFormat.is24HourFormat(this)), soften = settings.soften, - pastDisplay = settings.pastDisplay, - showToday = settings.showToday, - widgetSize = settings.widgetSize, + pastDisplay = options.pastDisplay, + showToday = options.showToday, + textSize = options.textSize, now = Clock.System.now(), ) } @@ -167,30 +197,35 @@ internal fun monthWindowCenter(index: Int): Int = Math.floorDiv(index, 3) * 3 + * stale. */ private object MonthSourceCache { - private class Entry(val center: Int, val stamp: Long, val source: MonthWidgetSource.Ready) + private class Entry(val center: Int, val stamp: Long, val source: Themed) private val entries = ConcurrentHashMap() - fun get(id: GlanceId, center: Int, stamp: Long): MonthWidgetSource.Ready? = - entries[id]?.takeIf { it.center == center && it.stamp == stamp && it.source.today == today(systemZone()) } + fun get(id: GlanceId, center: Int, stamp: Long): Themed? = + entries[id]?.takeIf { it.center == center && it.stamp == stamp && it.source.content.today == today(systemZone()) } ?.source - fun put(id: GlanceId, center: Int, stamp: Long, source: MonthWidgetSource) { - if (source is MonthWidgetSource.Ready) entries[id] = Entry(center, stamp, source) + fun put(id: GlanceId, center: Int, stamp: Long, source: Themed) { + val ready = source.content as? MonthWidgetSource.Ready ?: return + entries[id] = Entry(center, stamp, Themed(ready, source.appTheme)) } } -/** [loadMonthWidgetSource] for widget [id], served from [MonthSourceCache] while its [stamp] holds. */ -internal suspend fun Context.loadMonthWidgetSource(id: GlanceId, centerIndex: Int, stamp: Long): MonthWidgetSource = +/** [loadThemedMonthSource] for widget [id], served from [MonthSourceCache] while its [stamp] holds. */ +internal suspend fun Context.loadCachedMonthSource(id: GlanceId, centerIndex: Int, stamp: Long): Themed = MonthSourceCache.get(id, centerIndex, stamp) - ?: loadMonthWidgetSource(centerIndex).also { MonthSourceCache.put(id, centerIndex, stamp, it) } + ?: loadThemedMonthSource(centerIndex).also { MonthSourceCache.put(id, centerIndex, stamp, it) } -/** Read backing the month widget's grid for the window around [centerIndex]. */ -private suspend fun Context.loadMonthWidgetSource(centerIndex: Int): MonthWidgetSource { +/** Read backing the month widget's grid for the window around [centerIndex], with the app theme. */ +internal suspend fun Context.loadThemedMonthSource(centerIndex: Int): Themed { + val settings = widgetEntryPoint().settingsPrefs().widgetSettings.first() + return Themed(queryMonthSource(centerIndex, settings), settings.themeMode) +} + +private suspend fun Context.queryMonthSource(centerIndex: Int, settings: WidgetSettings): MonthWidgetSource { if (!hasCalendarPermission()) return MonthWidgetSource.NeedsPermission val zone = systemZone() val ep = widgetEntryPoint() - val settings = ep.settingsPrefs().widgetSettings.first() val months = (centerIndex - MONTH_WIDGET_PAD_MONTHS)..(centerIndex + MONTH_WIDGET_PAD_MONTHS) // Padded by the grid's spill into the neighbouring months. val from = firstDayOfMonthIndex(months.first) diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/widget/WidgetRedraw.kt b/app/src/main/java/de/jeanlucmakiola/calendula/widget/WidgetRedraw.kt index 222228d..5425900 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/widget/WidgetRedraw.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/widget/WidgetRedraw.kt @@ -1,6 +1,8 @@ package de.jeanlucmakiola.calendula.widget import android.app.PendingIntent +import android.appwidget.AppWidgetManager +import android.content.ComponentName import android.content.Context import android.content.Intent import android.os.SystemClock @@ -14,6 +16,8 @@ import de.jeanlucmakiola.calendula.widget.month.MonthWidgetReceiver import de.jeanlucmakiola.floret.glance.WidgetRefresher import de.jeanlucmakiola.floret.glance.WidgetRollover import de.jeanlucmakiola.floret.glance.updatePlaced +import de.jeanlucmakiola.floret.glance.updateWithRefresh +import androidx.glance.appwidget.GlanceAppWidgetManager import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineStart @@ -63,6 +67,18 @@ internal suspend fun redrawWidgets(context: Context) { private suspend fun redrawAgendaWidgets(context: Context) = AgendaWidget().updatePlaced(context, AgendaWidgetReceiver::class.java, bumpRefreshStamp = true) +/** Redraws only the agenda widgets with no options of their own, which still follow the app's. */ +private suspend fun redrawUnconfiguredAgendaWidgets(context: Context) { + val appContext = context.applicationContext + val ids = AppWidgetManager.getInstance(appContext) + ?.getAppWidgetIds(ComponentName(appContext, AgendaWidgetReceiver::class.java)) ?: return + val glanceManager = GlanceAppWidgetManager(appContext) + ids.forEach { appWidgetId -> + val glanceId = glanceManager.getGlanceIdBy(appWidgetId) + if (!appContext.widgetState(glanceId).hasAgendaOptions) AgendaWidget().updateWithRefresh(appContext, glanceId) + } +} + private suspend fun redrawMonthWidgets(context: Context) = MonthWidget().updatePlaced(context, MonthWidgetReceiver::class.java, bumpRefreshStamp = true) @@ -80,8 +96,9 @@ internal fun isProviderEcho(): Boolean { * Redraws the widgets on the app's own calendar writes, visibility changes and * the settings they draw with, so those don't wait for `PROVIDER_CHANGED`, * which arrives seconds late or not at all (#420, #229). Changes from outside - * the app are [WidgetUpdateReceiver]'s. A setting only the agenda widget draws - * with leaves the month widget alone. Started once from `CalendulaApp`. + * the app are [WidgetUpdateReceiver]'s. The app's agenda options only redraw + * agenda widgets that have none of their own (#292). Started once from + * `CalendulaApp`. */ @Singleton class WidgetRefreshCoordinator @Inject constructor( @@ -96,14 +113,20 @@ class WidgetRefreshCoordinator @Inject constructor( fun start() { if (!started.compareAndSet(false, true)) return val agenda = WidgetRefresher(scope) { redrawAgendaWidgets(context) } + val unconfiguredAgenda = WidgetRefresher(scope) { redrawUnconfiguredAgendaWidgets(context) } val month = WidgetRefresher(scope) { redrawMonthWidgets(context) } watch(repository.localChanges) { lastLocalChange.set(SystemClock.elapsedRealtime()) agenda.request() month.request() } - watch(prefs.widgetSettings.drop(1)) { agenda.request() } - watch(prefs.widgetSettings.map { it.month }.distinctUntilChanged().drop(1)) { month.request() } + watch(prefs.widgetSettings.map { it.shared }.distinctUntilChanged().drop(1)) { + agenda.request() + month.request() + } + watch(prefs.widgetSettings.map { it.agendaDefaults }.distinctUntilChanged().drop(1)) { + unconfiguredAgenda.request() + } } private fun watch(changes: Flow<*>, onChange: () -> Unit) { diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/widget/WidgetTheme.kt b/app/src/main/java/de/jeanlucmakiola/calendula/widget/WidgetTheme.kt index 3fba7de..8809be4 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/widget/WidgetTheme.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/widget/WidgetTheme.kt @@ -5,23 +5,34 @@ import androidx.glance.text.TextDecoration import de.jeanlucmakiola.calendula.ui.theme.CalendulaDarkFallback import de.jeanlucmakiola.calendula.ui.theme.CalendulaLightFallback import de.jeanlucmakiola.floret.glance.FloretGlanceTheme +import de.jeanlucmakiola.floret.glance.WidgetSurface import de.jeanlucmakiola.floret.glance.WidgetThemeMode +import de.jeanlucmakiola.floret.prefs.ThemeMode /** * Glance equivalent of `CalendulaTheme`: Material You on API 31+, the app's - * hand-tuned fallback schemes below that, following the system's light/dark. + * hand-tuned fallback schemes below that, in the light/dark [mode] the widget + * was configured with (#292). */ @Composable -fun CalendulaGlanceTheme(content: @Composable () -> Unit) { +fun CalendulaGlanceTheme(mode: WidgetThemeMode, content: @Composable () -> Unit) { FloretGlanceTheme( lightScheme = CalendulaLightFallback, darkScheme = CalendulaDarkFallback, dynamicColor = true, - mode = WidgetThemeMode.SYSTEM, + mode = mode, content = content, ) } +/** A widget's root: its configured theme and background around [content]. */ +@Composable +internal fun CalendulaWidgetRoot(appearance: WidgetAppearance, appTheme: ThemeMode, content: @Composable () -> Unit) { + CalendulaGlanceTheme(appearance.theme.resolve(appTheme)) { + WidgetSurface(background = appearance.background, content = content) + } +} + /** * Glance's counterpart to the app's `declinedDecoration`: a declined invitation * reads the same on the home screen as it does inside the app (#180). diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/widget/agenda/AgendaWidget.kt b/app/src/main/java/de/jeanlucmakiola/calendula/widget/agenda/AgendaWidget.kt index f027750..1695baa 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/widget/agenda/AgendaWidget.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/widget/agenda/AgendaWidget.kt @@ -16,7 +16,6 @@ import androidx.glance.action.ActionParameters import androidx.glance.action.clickable import androidx.glance.appwidget.GlanceAppWidget import androidx.glance.appwidget.SizeMode -import androidx.glance.color.ColorProvider import androidx.glance.appwidget.action.ActionCallback import androidx.glance.appwidget.action.actionRunCallback import androidx.glance.appwidget.action.actionStartActivity @@ -55,9 +54,21 @@ import de.jeanlucmakiola.floret.locale.localizedDateFormatter import de.jeanlucmakiola.calendula.ui.common.eventAccent import de.jeanlucmakiola.calendula.ui.common.eventFill import de.jeanlucmakiola.calendula.widget.AgendaWidgetData -import de.jeanlucmakiola.calendula.widget.CalendulaGlanceTheme import de.jeanlucmakiola.calendula.widget.WidgetSize -import de.jeanlucmakiola.calendula.widget.loadAgendaWidgetData +import de.jeanlucmakiola.calendula.widget.CalendulaWidgetRoot +import de.jeanlucmakiola.calendula.widget.WidgetAppearance +import de.jeanlucmakiola.calendula.widget.loadAgendaWidget +import de.jeanlucmakiola.calendula.widget.widgetAppearance +import de.jeanlucmakiola.floret.components.Position +import de.jeanlucmakiola.floret.components.positionOf +import de.jeanlucmakiola.floret.glance.LocalWidgetBackground +import de.jeanlucmakiola.floret.glance.WidgetBackground +import de.jeanlucmakiola.floret.glance.WidgetGroupedRow +import de.jeanlucmakiola.floret.glance.themedColor +import de.jeanlucmakiola.floret.glance.wallpaperBacking +import de.jeanlucmakiola.floret.prefs.ThemeMode +import androidx.datastore.preferences.core.Preferences +import androidx.glance.currentState import de.jeanlucmakiola.floret.glance.WidgetData import de.jeanlucmakiola.floret.glance.currentRefreshStamp import de.jeanlucmakiola.floret.glance.refreshStamp @@ -89,18 +100,40 @@ class AgendaWidget : GlanceAppWidget() { override suspend fun provideGlance(context: Context, id: GlanceId) { val stamp = refreshStamp(context, id) - val widgetData = WidgetData(context.loadAgendaWidgetData(), stamp) + val widgetData = WidgetData(context.loadAgendaWidget(id), stamp) provideContent { // A running session never re-runs this preamble, so every redraw // moves the stamp and the data reloads here instead (#420). - val data = widgetData.current(currentRefreshStamp()) { context.loadAgendaWidgetData() } - CalendulaGlanceTheme { - AgendaWidgetBody(data = data) - } + val loaded = widgetData.current(currentRefreshStamp()) { context.loadAgendaWidget(id) } + AgendaWidgetContent( + data = loaded.content, + appearance = currentState().widgetAppearance, + appTheme = loaded.appTheme, + ) } } } +/** + * The whole agenda widget, shared with its config screen's preview (#292). The + * [preview] draws its rows as a plain column, which RemoteViews can show + * outside a widget host on every Android version. + */ +@Composable +internal fun AgendaWidgetContent( + data: AgendaWidgetData, + appearance: WidgetAppearance, + appTheme: ThemeMode, + preview: Boolean = false, +) { + CalendulaWidgetRoot(appearance, appTheme) { + AgendaWidgetBody(data = data, preview = preview) + } +} + +/** Rows a preview draws: more than any widget shows at once. */ +private const val PREVIEW_ROWS = 16 + /** Reloads and redraws the tapped widget (header refresh button). */ class RefreshAgendaAction : ActionCallback { override suspend fun onAction(context: Context, glanceId: GlanceId, parameters: ActionParameters) { @@ -119,30 +152,31 @@ private const val MAX_AGENDA_ROWS = 100 /** Flat row model so the [LazyColumn] can mix day headers and events. */ private sealed interface AgendaRow { - data class Header(val date: LocalDate, val today: LocalDate) : AgendaRow - data class Event(val date: LocalDate, val event: EventInstance) : AgendaRow + val date: LocalDate + + data class Header(override val date: LocalDate, val today: LocalDate) : AgendaRow + data class Event(override val date: LocalDate, val event: EventInstance) : AgendaRow /** "Nothing left today" line under an anchored, event-less today (#35). */ - data class Placeholder(val date: LocalDate) : AgendaRow + data class Placeholder(override val date: LocalDate) : AgendaRow } @Composable -private fun AgendaWidgetBody(data: AgendaWidgetData) { +private fun AgendaWidgetBody(data: AgendaWidgetData, preview: Boolean) { // The stripe is re-resolved against the system font scale so it tracks the // sp-sized text beside it instead of drifting at large accessibility settings. - val size = (data as? AgendaWidgetData.Ready)?.widgetSize ?: WidgetSize.SMALL + val size = (data as? AgendaWidgetData.Ready)?.textSize ?: WidgetSize.SMALL val fontScale = androidx.glance.LocalContext.current.resources.configuration.fontScale val metrics = metricsFor(size).scaledForFont(fontScale) Column( modifier = GlanceModifier .fillMaxSize() - .background(GlanceTheme.colors.surface) .padding(horizontal = 8.dp, vertical = 6.dp), ) { AgendaHeader(metrics) Spacer(GlanceModifier.height(4.dp)) when (data) { AgendaWidgetData.NeedsPermission -> WidgetMessage(R.string.widget_needs_permission, metrics) - is AgendaWidgetData.Ready -> AgendaRows(data = data, metrics = metrics) + is AgendaWidgetData.Ready -> AgendaRows(data = data, metrics = metrics, preview = preview) } } } @@ -151,6 +185,7 @@ private fun AgendaWidgetBody(data: AgendaWidgetData) { private fun AgendaRows( data: AgendaWidgetData.Ready, metrics: AgendaMetrics, + preview: Boolean, ) { val pastDisplay = data.pastDisplay // Drop finished events (and any day they leave empty) when the mode is @@ -189,23 +224,59 @@ private fun AgendaRows( // stranded with nothing under it. .take(MAX_AGENDA_ROWS) .dropLastWhile { it is AgendaRow.Header } - LazyColumn(modifier = GlanceModifier.fillMaxSize()) { - items(rows.size) { index -> - when (val row = rows[index]) { - is AgendaRow.Header -> DayHeaderRow(row.date, row.today, metrics) - is AgendaRow.Placeholder -> PlaceholderRow(row.date, metrics) - is AgendaRow.Event -> EventRow( - event = row.event, - day = row.date, - soften = data.soften, - is24Hour = data.is24Hour, - dimmed = pastDisplay == PastEventDisplay.DIM && - row.event.hasEnded(data.now), - metrics = metrics, - ) + // On a transparent widget each day becomes one card of grouped rows, + // so it stays readable over the wallpaper (#292). + val positions = if (LocalWidgetBackground.current == WidgetBackground.TRANSPARENT) dayPositions(rows) else null + val item: @Composable (Int) -> Unit = { index -> + val row = rows[index] + if (positions != null) { + WidgetGroupedRow( + position = positions[index], + modifier = if (row is AgendaRow.Header && index > 0) GlanceModifier.padding(top = 6.dp) else GlanceModifier, + color = GlanceTheme.colors.widgetBackground, + ) { + AgendaRowContent(row, data, metrics) } + } else { + AgendaRowContent(row, data, metrics) } } + if (preview) { + Column(modifier = GlanceModifier.fillMaxSize()) { + repeat(minOf(rows.size, PREVIEW_ROWS)) { item(it) } + } + } else { + LazyColumn(modifier = GlanceModifier.fillMaxSize()) { + items(rows.size) { item(it) } + } + } + } +} + +/** Where each row sits among the rows of its day, for the grouped-card shapes. */ +private fun dayPositions(rows: List): List = buildList { + var start = 0 + for (end in 1..rows.size) { + if (end == rows.size || rows[end].date != rows[start].date) { + for (i in start until end) add(positionOf(i - start, end - start)) + start = end + } + } +} + +@Composable +private fun AgendaRowContent(row: AgendaRow, data: AgendaWidgetData.Ready, metrics: AgendaMetrics) { + when (row) { + is AgendaRow.Header -> DayHeaderRow(row.date, row.today, metrics) + is AgendaRow.Placeholder -> PlaceholderRow(row.date, metrics) + is AgendaRow.Event -> EventRow( + event = row.event, + day = row.date, + soften = data.soften, + is24Hour = data.is24Hour, + dimmed = data.pastDisplay == PastEventDisplay.DIM && row.event.hasEnded(data.now), + metrics = metrics, + ) } } @@ -213,7 +284,7 @@ private fun AgendaRows( private fun AgendaHeader(metrics: AgendaMetrics) { val context = androidx.glance.LocalContext.current Row( - modifier = GlanceModifier.fillMaxWidth().padding(horizontal = 4.dp), + modifier = GlanceModifier.fillMaxWidth().wallpaperBacking().padding(horizontal = 4.dp), verticalAlignment = Alignment.CenterVertically, ) { // Tap the "Upcoming" title to open the app on the user's default view @@ -340,7 +411,7 @@ private fun EventRow( fun stripe(dark: Boolean) = eventAccent(event.color, dark, soften).let { if (dimmed) it.copy(alpha = EventDimAlpha) else it } - val stripeColor = ColorProvider(day = stripe(dark = false), night = stripe(dark = true)) + val stripeColor = themedColor(day = stripe(dark = false), night = stripe(dark = true)) val titleColor = if (dimmed) GlanceTheme.colors.onSurfaceVariant else GlanceTheme.colors.onSurface Row( modifier = GlanceModifier @@ -396,6 +467,7 @@ private fun WidgetMessage(resId: Int, metrics: AgendaMetrics) { Text( text = context.getString(resId), style = TextStyle(color = GlanceTheme.colors.onSurfaceVariant, fontSize = metrics.message), + modifier = GlanceModifier.wallpaperBacking().padding(horizontal = 12.dp, vertical = 8.dp), ) } } diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/widget/agenda/AgendaWidgetReceiver.kt b/app/src/main/java/de/jeanlucmakiola/calendula/widget/agenda/AgendaWidgetReceiver.kt index 2ed8638..ee48a12 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/widget/agenda/AgendaWidgetReceiver.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/widget/agenda/AgendaWidgetReceiver.kt @@ -7,6 +7,7 @@ import androidx.glance.appwidget.GlanceAppWidget import androidx.glance.appwidget.GlanceAppWidgetReceiver import de.jeanlucmakiola.calendula.widget.syncWidgetRollover import de.jeanlucmakiola.floret.glance.reloadOnUpdate +import de.jeanlucmakiola.floret.glance.restoreState /** * Host-facing receiver for the agenda widget. Declared in the manifest with the @@ -31,6 +32,11 @@ class AgendaWidgetReceiver : GlanceAppWidgetReceiver() { syncWidgetRollover(context) } + /** A backup restore or device transfer renumbered the widgets: keep each one's settings (#292). */ + override fun onRestored(context: Context, oldWidgetIds: IntArray, newWidgetIds: IntArray) { + restoreState(context, oldWidgetIds, newWidgetIds) + } + /** * Self-heal on the system's own `updatePeriodMillis` wake-up — see * [de.jeanlucmakiola.calendula.widget.month.MonthWidgetReceiver.onUpdate]. diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/widget/config/PlacedWidgets.kt b/app/src/main/java/de/jeanlucmakiola/calendula/widget/config/PlacedWidgets.kt new file mode 100644 index 0000000..4425ac4 --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/calendula/widget/config/PlacedWidgets.kt @@ -0,0 +1,31 @@ +package de.jeanlucmakiola.calendula.widget.config + +import android.appwidget.AppWidgetManager +import android.content.ComponentName +import android.content.Context +import androidx.glance.appwidget.GlanceAppWidgetManager +import de.jeanlucmakiola.calendula.widget.WidgetAppearance +import de.jeanlucmakiola.calendula.widget.widgetAppearance +import de.jeanlucmakiola.calendula.widget.widgetState + +/** One of the app's widgets on the home screen, for the settings list. */ +data class PlacedWidget(val appWidgetId: Int, val kind: WidgetKind, val appearance: WidgetAppearance) + +/** + * Every placed widget, agenda ones first, each in placement order. An id the + * host bound but never laid out (a placement it abandoned without freeing the + * id) has no size yet and is left out, as is one removed while this reads. + */ +suspend fun Context.placedWidgets(): List { + val manager = AppWidgetManager.getInstance(this) ?: return emptyList() + val glanceManager = GlanceAppWidgetManager(this) + return WidgetKind.entries.flatMap { kind -> + manager.getAppWidgetIds(ComponentName(this, kind.receiver)).sorted().mapNotNull { id -> + if (manager.getAppWidgetOptions(id).getInt(AppWidgetManager.OPTION_APPWIDGET_MIN_WIDTH) <= 0) { + return@mapNotNull null + } + val glanceId = runCatching { glanceManager.getGlanceIdBy(id) }.getOrNull() ?: return@mapNotNull null + PlacedWidget(id, kind, widgetState(glanceId).widgetAppearance) + } + } +} diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/widget/config/WidgetConfigActivity.kt b/app/src/main/java/de/jeanlucmakiola/calendula/widget/config/WidgetConfigActivity.kt new file mode 100644 index 0000000..12cddfb --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/calendula/widget/config/WidgetConfigActivity.kt @@ -0,0 +1,130 @@ +package de.jeanlucmakiola.calendula.widget.config + +import android.appwidget.AppWidgetManager +import android.content.Context +import android.content.Intent +import android.os.Bundle +import android.widget.Toast +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.activity.enableEdgeToEdge +import androidx.activity.viewModels +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.platform.LocalContext +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.lifecycle.lifecycleScope +import androidx.lifecycle.repeatOnLifecycle +import dagger.hilt.android.AndroidEntryPoint +import de.jeanlucmakiola.calendula.R +import de.jeanlucmakiola.calendula.data.prefs.SettingsKeys +import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs +import de.jeanlucmakiola.calendula.domain.FontRole +import de.jeanlucmakiola.calendula.ui.theme.AppFontSettings +import de.jeanlucmakiola.calendula.ui.theme.CalendulaTheme +import de.jeanlucmakiola.calendula.ui.theme.WindowThemeCache +import de.jeanlucmakiola.calendula.ui.theme.calendulaTypography +import de.jeanlucmakiola.calendula.ui.theme.resolveFontFamily +import de.jeanlucmakiola.floret.prefs.ThemeMode +import de.jeanlucmakiola.floret.prefs.get +import de.jeanlucmakiola.floret.prefs.isDark +import de.jeanlucmakiola.floret.prefs.isPureBlack +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.launch +import javax.inject.Inject + +/** + * A widget's config screen (#292): opened by the launcher when the widget is + * placed, from its "Edit widget" action on Android 12+, and from the app's + * widget settings. Shows over the wallpaper, so a transparent widget previews + * against the real home screen. + */ +@AndroidEntryPoint +class WidgetConfigActivity : ComponentActivity() { + + @Inject lateinit var settingsPrefs: SettingsPrefs + + private val viewModel: WidgetConfigViewModel by viewModels() + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + val appWidgetId = viewModel.appWidgetId + // Backing out of a fresh placement leaves no unconfigured widget behind. + setResult(RESULT_CANCELED, result(appWidgetId)) + if (appWidgetId == AppWidgetManager.INVALID_APPWIDGET_ID) { + finish() + return + } + enableEdgeToEdge() + lifecycleScope.launch { + repeatOnLifecycle(Lifecycle.State.STARTED) { + viewModel.events.collect { event -> + when (event) { + WidgetConfigEvent.SAVED -> { + setResult(RESULT_OK, result(appWidgetId)) + finish() + } + WidgetConfigEvent.SAVE_FAILED -> + Toast.makeText(this@WidgetConfigActivity, R.string.widget_config_save_failed, Toast.LENGTH_SHORT).show() + } + } + } + } + val fontSettings = settingsPrefs.data.map { + AppFontSettings( + brand = it[SettingsKeys.brandFont], + plain = it[SettingsKeys.plainFont], + brandStamp = it[SettingsKeys.brandFontStamp], + plainStamp = it[SettingsKeys.plainFontStamp], + ) + } + // Seeded from MainActivity's mirrored copy, so an app theme override + // shows from the first frame. + val cachedTheme = WindowThemeCache.read(this) ?: ThemeMode.SYSTEM + val cachedDynamicColor = WindowThemeCache.readDynamicColor(this) ?: true + setContent { + val themeMode by settingsPrefs.themeMode.collectAsStateWithLifecycle(cachedTheme) + val dynamicColor by settingsPrefs.dynamicColor.collectAsStateWithLifecycle(cachedDynamicColor) + val fonts by fontSettings.collectAsStateWithLifecycle(initialValue = null) + val context = LocalContext.current + // Nothing until the font choice is known, rather than a frame in the default one. + val typography = remember(fonts, context) { + fonts?.let { + calendulaTypography( + brand = resolveFontFamily(it.brand, FontRole.BRAND, context), + plain = resolveFontFamily(it.plain, FontRole.PLAIN, context), + ) + } + } ?: return@setContent + CalendulaTheme( + darkTheme = themeMode.isDark(isSystemInDarkTheme()), + dynamicColor = dynamicColor, + pureBlack = themeMode.isPureBlack, + typography = typography, + ) { + val state by viewModel.state.collectAsStateWithLifecycle() + if (state is WidgetConfigUiState.Missing) LaunchedEffect(Unit) { finish() } + WidgetConfigScreen( + state = state, + onClose = ::finish, + onSave = viewModel::save, + onRetry = viewModel::retry, + onAppearance = viewModel::setAppearance, + onAgendaOptions = viewModel::setAgendaOptions, + ) + } + } + } + + private fun result(appWidgetId: Int) = Intent().putExtra(AppWidgetManager.EXTRA_APPWIDGET_ID, appWidgetId) + + companion object { + /** Opens placed widget [appWidgetId]'s config screen from inside the app. */ + fun intent(context: Context, appWidgetId: Int): Intent = + Intent(context, WidgetConfigActivity::class.java) + .putExtra(AppWidgetManager.EXTRA_APPWIDGET_ID, appWidgetId) + } +} diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/widget/config/WidgetConfigScreen.kt b/app/src/main/java/de/jeanlucmakiola/calendula/widget/config/WidgetConfigScreen.kt new file mode 100644 index 0000000..603e29e --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/calendula/widget/config/WidgetConfigScreen.kt @@ -0,0 +1,328 @@ +package de.jeanlucmakiola.calendula.widget.config + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.navigationBarsPadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Close +import androidx.compose.material3.Button +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.FilledTonalButton +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.DpSize +import androidx.compose.ui.unit.dp +import de.jeanlucmakiola.calendula.R +import de.jeanlucmakiola.calendula.data.prefs.PastEventDisplay +import de.jeanlucmakiola.calendula.ui.common.AgendaRangePicker +import de.jeanlucmakiola.calendula.ui.common.agendaRangeLabel +import de.jeanlucmakiola.calendula.ui.settings.pastEventDisplayLabelRes +import de.jeanlucmakiola.calendula.widget.AgendaWidgetOptions +import de.jeanlucmakiola.calendula.widget.WidgetAppearance +import de.jeanlucmakiola.calendula.widget.WidgetSize +import de.jeanlucmakiola.calendula.widget.WidgetTheme +import de.jeanlucmakiola.calendula.widget.agenda.AgendaWidgetContent +import de.jeanlucmakiola.calendula.widget.agenda.metricsFor +import de.jeanlucmakiola.calendula.widget.month.MonthWidgetContent +import de.jeanlucmakiola.floret.components.GroupedChoiceRow +import de.jeanlucmakiola.floret.components.GroupedHint +import de.jeanlucmakiola.floret.components.GroupedRow +import de.jeanlucmakiola.floret.components.GroupedSectionHeader +import de.jeanlucmakiola.floret.components.GroupedSwitchRow +import de.jeanlucmakiola.floret.components.OptionPicker +import de.jeanlucmakiola.floret.components.Position +import de.jeanlucmakiola.floret.glance.GlanceWidgetPreview +import de.jeanlucmakiola.floret.glance.WallpaperWindow +import de.jeanlucmakiola.floret.glance.WidgetBackground +import kotlin.math.roundToInt + +private val PreviewMaxHeight = 280.dp + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +internal fun WidgetConfigScreen( + state: WidgetConfigUiState, + onClose: () -> Unit, + onSave: () -> Unit, + onRetry: () -> Unit, + onAppearance: (WidgetAppearance) -> Unit, + onAgendaOptions: (AgendaWidgetOptions) -> Unit, +) { + val ready = state as? WidgetConfigUiState.Ready + val surface = MaterialTheme.colorScheme.surface + Column(modifier = Modifier.fillMaxSize()) { + TopAppBar( + title = { + Text( + text = ready?.let { stringResource(it.kind.label) }.orEmpty(), + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + }, + navigationIcon = { + IconButton(onClick = onClose) { + Icon(Icons.Default.Close, contentDescription = stringResource(R.string.event_edit_close)) + } + }, + actions = { + Button( + onClick = onSave, + enabled = ready != null && !ready.saving, + modifier = Modifier.padding(end = 8.dp), + ) { + Text(stringResource(R.string.dialog_save)) + } + }, + colors = TopAppBarDefaults.topAppBarColors(containerColor = surface), + ) + when (state) { + WidgetConfigUiState.Loading, WidgetConfigUiState.Missing -> Box( + modifier = Modifier.fillMaxSize().background(surface), + contentAlignment = Alignment.Center, + ) { + CircularProgressIndicator() + } + WidgetConfigUiState.Failed -> Column( + modifier = Modifier.fillMaxSize().background(surface).padding(24.dp), + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Text( + text = stringResource(R.string.widget_config_load_failed), + style = MaterialTheme.typography.bodyLarge, + textAlign = TextAlign.Center, + ) + Spacer(Modifier.height(16.dp)) + FilledTonalButton(onClick = onRetry) { Text(stringResource(R.string.state_retry)) } + } + is WidgetConfigUiState.Ready -> { + WallpaperWindow(color = surface) { + val maxSize = DpSize(maxWidth, PreviewMaxHeight) + when (val preview = state.preview) { + null -> Box( + modifier = Modifier.padding(vertical = 16.dp).height(maxSize.height / 2), + contentAlignment = Alignment.Center, + ) { + if (state.previewFailed) PreviewFailed() else CircularProgressIndicator() + } + else -> GlanceWidgetPreview( + size = state.placedSize, + maxSize = maxSize, + preview, + state.appearance, + state.appTheme, + modifier = Modifier.padding(vertical = 16.dp), + placeholder = { CircularProgressIndicator() }, + failure = { PreviewFailed() }, + ) { + when (preview) { + is WidgetPreviewData.Agenda -> + AgendaWidgetContent(preview.data, state.appearance, state.appTheme, preview = true) + is WidgetPreviewData.Month -> + MonthWidgetContent(preview.source, preview.index, state.appearance, state.appTheme) + } + } + } + } + ConfigOptions( + state = state, + onAppearance = onAppearance, + onAgendaOptions = onAgendaOptions, + modifier = Modifier + .weight(1f) + .background(surface), + ) + } + } + } +} + +@Composable +private fun PreviewFailed() { + Text( + text = stringResource(R.string.widget_config_preview_failed), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurface, + textAlign = TextAlign.Center, + modifier = Modifier + .background(MaterialTheme.colorScheme.surface, MaterialTheme.shapes.large) + .padding(horizontal = 16.dp, vertical = 8.dp), + ) +} + +internal val WidgetKind.label: Int + get() = when (this) { + WidgetKind.AGENDA -> R.string.widget_agenda_label + WidgetKind.MONTH -> R.string.widget_month_label + } + +@Composable +private fun ConfigOptions( + state: WidgetConfigUiState.Ready, + onAppearance: (WidgetAppearance) -> Unit, + onAgendaOptions: (AgendaWidgetOptions) -> Unit, + modifier: Modifier = Modifier, +) { + Column( + modifier = modifier + .verticalScroll(rememberScrollState()) + .navigationBarsPadding() + .padding(bottom = 16.dp), + ) { + GroupedSectionHeader(stringResource(R.string.settings_section_appearance)) + GroupedChoiceRow( + title = stringResource(R.string.widget_config_background), + options = WidgetBackground.entries, + selected = state.appearance.background, + label = { backgroundLabel(it) }, + onSelect = { onAppearance(state.appearance.copy(background = it)) }, + position = Position.Top, + ) + GroupedChoiceRow( + title = stringResource(R.string.settings_theme), + options = WidgetTheme.entries, + selected = state.appearance.theme, + label = { themeLabel(it) }, + onSelect = { onAppearance(state.appearance.copy(theme = it)) }, + position = Position.Bottom, + ) + state.agenda?.let { options -> + AgendaOptions(options = options, state = state, onChange = onAgendaOptions) + } + } +} + +@Composable +private fun AgendaOptions( + options: AgendaWidgetOptions, + state: WidgetConfigUiState.Ready, + onChange: (AgendaWidgetOptions) -> Unit, +) { + var showRange by rememberSaveable { mutableStateOf(false) } + var showSize by rememberSaveable { mutableStateOf(false) } + var showPast by rememberSaveable { mutableStateOf(false) } + + GroupedSectionHeader(stringResource(R.string.widget_config_content)) + GroupedRow( + title = stringResource(R.string.widget_config_range), + summary = agendaRangeLabel(options.range), + position = Position.Top, + onClick = { showRange = true }, + ) + GroupedRow( + title = stringResource(R.string.widget_config_text_size), + summary = widgetSizeLabel(options.textSize), + position = Position.Middle, + onClick = { showSize = true }, + ) + GroupedRow( + title = stringResource(R.string.settings_past_events), + summary = stringResource(pastEventDisplayLabelRes(options.pastDisplay)), + position = Position.Middle, + onClick = { showPast = true }, + ) + GroupedSwitchRow( + title = stringResource(R.string.settings_agenda_show_today), + checked = options.showToday, + onCheckedChange = { onChange(options.copy(showToday = it)) }, + position = Position.Bottom, + ) + + if (showRange) { + AgendaRangePicker( + title = stringResource(R.string.widget_config_range), + description = stringResource(R.string.settings_agenda_widget_range_hint), + selected = options.range, + weekStart = state.weekStart, + onSelect = { onChange(options.copy(range = it)) }, + onDismiss = { showRange = false }, + ) + } + if (showSize) { + OptionPicker( + title = stringResource(R.string.widget_config_text_size), + header = { GroupedHint(stringResource(R.string.settings_widget_size_hint), verticalPadding = 8.dp) }, + predictiveBack = true, + options = WidgetSize.entries, + selected = options.textSize, + label = { widgetSizeLabel(it) }, + summary = { widgetSizeSummary(it) }, + onSelect = { onChange(options.copy(textSize = it)) }, + onDismiss = { showSize = false }, + ) + } + if (showPast) { + OptionPicker( + title = stringResource(R.string.settings_past_events), + header = { GroupedHint(stringResource(R.string.settings_past_events_hint), verticalPadding = 8.dp) }, + predictiveBack = true, + options = PastEventDisplay.entries, + selected = options.pastDisplay, + label = { stringResource(pastEventDisplayLabelRes(it)) }, + onSelect = { onChange(options.copy(pastDisplay = it)) }, + onDismiss = { showPast = false }, + ) + } +} + +@Composable +internal fun backgroundLabel(background: WidgetBackground): String = stringResource( + when (background) { + WidgetBackground.SOLID -> R.string.widget_config_background_solid + WidgetBackground.TINTED -> R.string.widget_config_background_tinted + WidgetBackground.TRANSPARENT -> R.string.widget_config_background_transparent + }, +) + +@Composable +internal fun themeLabel(theme: WidgetTheme): String = stringResource( + when (theme) { + WidgetTheme.FOLLOW_APP -> R.string.widget_config_theme_follow_app + WidgetTheme.LIGHT -> R.string.settings_theme_light + WidgetTheme.DARK -> R.string.settings_theme_dark + }, +) + +/** + * What a size step does, as a percentage of the smallest one's event text. + * Derived from the widget's own [metricsFor] table rather than hardcoded. + */ +@Composable +private fun widgetSizeSummary(size: WidgetSize): String { + val base = metricsFor(WidgetSize.SMALL).eventTitle.value + val percent = (metricsFor(size).eventTitle.value / base * 100f).roundToInt() + return stringResource(R.string.settings_widget_size_summary, percent) +} + +@Composable +private fun widgetSizeLabel(size: WidgetSize): String = stringResource( + when (size) { + WidgetSize.SMALL -> R.string.settings_widget_size_small + WidgetSize.MEDIUM -> R.string.settings_widget_size_medium + WidgetSize.LARGE -> R.string.settings_widget_size_large + WidgetSize.EXTRA_LARGE -> R.string.settings_widget_size_extra_large + }, +) diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/widget/config/WidgetConfigViewModel.kt b/app/src/main/java/de/jeanlucmakiola/calendula/widget/config/WidgetConfigViewModel.kt new file mode 100644 index 0000000..71ac45a --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/calendula/widget/config/WidgetConfigViewModel.kt @@ -0,0 +1,242 @@ +package de.jeanlucmakiola.calendula.widget.config + +import android.appwidget.AppWidgetManager +import android.content.Context +import androidx.compose.ui.unit.DpSize +import androidx.compose.ui.unit.dp +import androidx.glance.appwidget.GlanceAppWidget +import androidx.glance.appwidget.GlanceAppWidgetManager +import androidx.lifecycle.SavedStateHandle +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import dagger.hilt.android.lifecycle.HiltViewModel +import dagger.hilt.android.qualifiers.ApplicationContext +import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs +import de.jeanlucmakiola.calendula.data.prefs.resolveFirstDay +import de.jeanlucmakiola.calendula.widget.AgendaWidgetData +import de.jeanlucmakiola.calendula.widget.AgendaWidgetOptions +import de.jeanlucmakiola.calendula.widget.MonthWidgetSource +import de.jeanlucmakiola.calendula.widget.WidgetAppearance +import de.jeanlucmakiola.calendula.widget.agenda.AgendaWidget +import de.jeanlucmakiola.calendula.widget.agenda.AgendaWidgetReceiver +import de.jeanlucmakiola.calendula.widget.loadAgendaWidgetData +import de.jeanlucmakiola.calendula.widget.loadThemedMonthSource +import de.jeanlucmakiola.calendula.widget.month.MonthWidget +import de.jeanlucmakiola.calendula.widget.month.MonthWidgetReceiver +import de.jeanlucmakiola.calendula.widget.monthIndexOf +import de.jeanlucmakiola.calendula.widget.monthWindowCenter +import de.jeanlucmakiola.calendula.widget.agendaWidgetOptions +import de.jeanlucmakiola.calendula.widget.putAgendaOptions +import de.jeanlucmakiola.calendula.widget.putAppearance +import de.jeanlucmakiola.calendula.widget.systemZone +import de.jeanlucmakiola.calendula.widget.today +import de.jeanlucmakiola.calendula.widget.widgetAppearance +import de.jeanlucmakiola.calendula.widget.widgetSettings +import de.jeanlucmakiola.calendula.widget.widgetState +import de.jeanlucmakiola.floret.glance.updateWithRefresh +import de.jeanlucmakiola.floret.prefs.ThemeMode +import javax.inject.Inject +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.Job +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.receiveAsFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import kotlinx.datetime.DayOfWeek +import java.util.Locale + +/** The widgets a config screen can edit, told apart by their receiver. */ +enum class WidgetKind(val receiver: Class<*>) { + AGENDA(AgendaWidgetReceiver::class.java), + MONTH(MonthWidgetReceiver::class.java), + ; + + fun widget(): GlanceAppWidget = when (this) { + AGENDA -> AgendaWidget() + MONTH -> MonthWidget() + } + + companion object { + fun of(context: Context, appWidgetId: Int): WidgetKind? { + val provider = AppWidgetManager.getInstance(context)?.getAppWidgetInfo(appWidgetId)?.provider ?: return null + return entries.firstOrNull { it.receiver.name == provider.className } + } + } +} + +/** What the preview draws, loaded with the draft's options. */ +sealed interface WidgetPreviewData { + data class Agenda(val data: AgendaWidgetData) : WidgetPreviewData + data class Month(val source: MonthWidgetSource, val index: Int) : WidgetPreviewData +} + +sealed interface WidgetConfigUiState { + data object Loading : WidgetConfigUiState + + /** The widget is gone, or isn't one of ours; the screen closes. */ + data object Missing : WidgetConfigUiState + + /** Its stored options couldn't be read. */ + data object Failed : WidgetConfigUiState + + data class Ready( + val kind: WidgetKind, + val appearance: WidgetAppearance, + /** Null for a widget with no content options. */ + val agenda: AgendaWidgetOptions?, + val appTheme: ThemeMode, + val weekStart: DayOfWeek, + /** The widget's size on the home screen, which the preview is laid out at. */ + val placedSize: DpSize, + /** Null until the first preview load lands. */ + val preview: WidgetPreviewData?, + /** The first preview load failed, so there's nothing to show. */ + val previewFailed: Boolean = false, + val saving: Boolean = false, + ) : WidgetConfigUiState +} + +/** One-time outcomes of a save, for the screen that's showing when they land. */ +enum class WidgetConfigEvent { SAVED, SAVE_FAILED } + +/** + * Backs [WidgetConfigActivity] (#292): the placed widget's stored options as an + * editable draft, a preview loaded with that draft, and the save that writes it + * into the widget's Glance state and redraws it. + */ +@HiltViewModel +class WidgetConfigViewModel @Inject constructor( + @ApplicationContext private val context: Context, + private val prefs: SettingsPrefs, + savedState: SavedStateHandle, +) : ViewModel() { + + val appWidgetId: Int = savedState[AppWidgetManager.EXTRA_APPWIDGET_ID] ?: AppWidgetManager.INVALID_APPWIDGET_ID + + private val _state = MutableStateFlow(WidgetConfigUiState.Loading) + val state: StateFlow = _state.asStateFlow() + + private val _events = Channel(Channel.BUFFERED) + val events: Flow = _events.receiveAsFlow() + + private var previewJob: Job? = null + + init { + viewModelScope.launch { load() } + } + + fun retry() { + if (_state.value != WidgetConfigUiState.Failed) return + _state.value = WidgetConfigUiState.Loading + viewModelScope.launch { load() } + } + + private suspend fun load() { + val kind = WidgetKind.of(context, appWidgetId) + if (kind == null) { + _state.value = WidgetConfigUiState.Missing + return + } + _state.value = try { + val stored = context.widgetState(GlanceAppWidgetManager(context).getGlanceIdBy(appWidgetId)) + val settings = prefs.widgetSettings.first() + WidgetConfigUiState.Ready( + kind = kind, + appearance = stored.widgetAppearance, + agenda = if (kind == WidgetKind.AGENDA) stored.agendaWidgetOptions(settings.agendaDefaults) else null, + appTheme = settings.themeMode, + weekStart = settings.weekStart.resolveFirstDay(Locale.getDefault()), + placedSize = placedSize(kind), + preview = null, + ) + } catch (e: CancellationException) { + throw e + } catch (_: IllegalArgumentException) { + // getGlanceIdBy: the widget was removed meanwhile. + WidgetConfigUiState.Missing + } catch (_: Exception) { + WidgetConfigUiState.Failed + } + reloadPreview() + } + + /** The size the launcher placed the widget at (portrait), or a default before it has reported one. */ + private fun placedSize(kind: WidgetKind): DpSize { + val options = AppWidgetManager.getInstance(context)?.getAppWidgetOptions(appWidgetId) + val width = options?.getInt(AppWidgetManager.OPTION_APPWIDGET_MIN_WIDTH) ?: 0 + val height = options?.getInt(AppWidgetManager.OPTION_APPWIDGET_MAX_HEIGHT) ?: 0 + if (width > 0 && height > 0) return DpSize(width.dp, height.dp) + return when (kind) { + WidgetKind.AGENDA -> DpSize(260.dp, 280.dp) + WidgetKind.MONTH -> DpSize(320.dp, 340.dp) + } + } + + fun setAppearance(appearance: WidgetAppearance) = edit { it.copy(appearance = appearance) } + + fun setAgendaOptions(options: AgendaWidgetOptions) { + edit { it.copy(agenda = options) } + reloadPreview() + } + + private fun edit(change: (WidgetConfigUiState.Ready) -> WidgetConfigUiState.Ready) { + _state.update { if (it is WidgetConfigUiState.Ready) change(it) else it } + } + + private fun reloadPreview() { + val ready = _state.value as? WidgetConfigUiState.Ready ?: return + previewJob?.cancel() + previewJob = viewModelScope.launch { + val preview = try { + loadPreview(ready) + } catch (e: CancellationException) { + throw e + } catch (_: Exception) { + // The options stay editable; the preview keeps what it had. + edit { it.copy(previewFailed = it.preview == null) } + return@launch + } + edit { it.copy(preview = preview, previewFailed = false) } + } + } + + private suspend fun loadPreview(ready: WidgetConfigUiState.Ready): WidgetPreviewData = when (ready.kind) { + WidgetKind.AGENDA -> { + val settings = prefs.widgetSettings.first() + WidgetPreviewData.Agenda(context.loadAgendaWidgetData(settings, requireNotNull(ready.agenda))) + } + WidgetKind.MONTH -> { + val index = monthIndexOf(today(systemZone())) + WidgetPreviewData.Month(context.loadThemedMonthSource(monthWindowCenter(index)).content, index) + } + } + + /** Writes the draft into the widget and redraws it, then reports how that went on [events]. */ + fun save() { + val ready = _state.value as? WidgetConfigUiState.Ready ?: return + if (ready.saving) return + edit { it.copy(saving = true) } + viewModelScope.launch { + try { + val glanceId = GlanceAppWidgetManager(context).getGlanceIdBy(appWidgetId) + ready.kind.widget().updateWithRefresh(context, glanceId) { state -> + state.putAppearance(ready.appearance) + ready.agenda?.let { state.putAgendaOptions(it) } + } + _events.send(WidgetConfigEvent.SAVED) + } catch (e: CancellationException) { + throw e + } catch (_: IllegalArgumentException) { + _state.value = WidgetConfigUiState.Missing + } catch (_: Exception) { + edit { it.copy(saving = false) } + _events.send(WidgetConfigEvent.SAVE_FAILED) + } + } + } +} diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/widget/month/MonthGrid.kt b/app/src/main/java/de/jeanlucmakiola/calendula/widget/month/MonthGrid.kt index 53a8815..01d70b3 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/widget/month/MonthGrid.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/widget/month/MonthGrid.kt @@ -31,11 +31,12 @@ internal class ViewIds(sdkInt: Int = Build.VERSION.SDK_INT) { val clickableSized = sized + 2 /** - * `MonthWidgetBody`'s views outside the week rows: its Column, the header - * row with three sized clickable icon boxes, their sized icons and the - * clickable title, the spacer, and the weekday row of sized labels. + * `MonthWidgetBody`'s views outside the week rows: the surface Box, its + * Column, the header row with three sized clickable icon boxes, their sized + * icons and the clickable title, the spacer, the grid's Column and the + * weekday row of sized labels. */ - val fixed = plain + plain + 3 * (clickableSized + sized) + clickablePlain + sized + plain + 7 * sized + val fixed = plain + plain + plain + 3 * (clickableSized + sized) + clickablePlain + sized + plain + plain + 7 * sized } /** A cell [cols] columns wide holding [item], or a merged gap where [item] is null. */ diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/widget/month/MonthWidget.kt b/app/src/main/java/de/jeanlucmakiola/calendula/widget/month/MonthWidget.kt index 332726e..c0443ad 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/widget/month/MonthWidget.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/widget/month/MonthWidget.kt @@ -26,7 +26,6 @@ import androidx.glance.action.actionParametersOf import androidx.glance.action.clickable import androidx.glance.appwidget.GlanceAppWidget import androidx.glance.appwidget.SizeMode -import androidx.glance.color.ColorProvider import androidx.glance.appwidget.action.ActionCallback import androidx.glance.appwidget.action.actionRunCallback import androidx.glance.appwidget.action.actionStartActivity @@ -63,9 +62,14 @@ import de.jeanlucmakiola.calendula.ui.month.MonthWeek import de.jeanlucmakiola.calendula.ui.month.layoutMonthWeeks import de.jeanlucmakiola.calendula.ui.common.eventFill import de.jeanlucmakiola.calendula.ui.common.eventInk -import de.jeanlucmakiola.calendula.widget.CalendulaGlanceTheme +import de.jeanlucmakiola.calendula.widget.CalendulaWidgetRoot +import de.jeanlucmakiola.calendula.widget.WidgetAppearance +import de.jeanlucmakiola.calendula.widget.widgetAppearance +import de.jeanlucmakiola.floret.glance.themedColor +import de.jeanlucmakiola.floret.glance.wallpaperBacking +import de.jeanlucmakiola.floret.prefs.ThemeMode import de.jeanlucmakiola.calendula.widget.MonthWidgetSource -import de.jeanlucmakiola.calendula.widget.loadMonthWidgetSource +import de.jeanlucmakiola.calendula.widget.loadCachedMonthSource import de.jeanlucmakiola.calendula.widget.monthWindowCenter import de.jeanlucmakiola.floret.glance.WidgetData import de.jeanlucmakiola.floret.glance.currentRefreshStamp @@ -134,16 +138,17 @@ class MonthWidget : GlanceAppWidget() { val stamp = refreshStamp(context, id) val initialCenter = monthWindowCenter(shown) // Shared by every size Exact composes, so each loads nothing of its own. - val widgetData = WidgetData(context.loadMonthWidgetSource(id, initialCenter, stamp), initialCenter, stamp) + val widgetData = WidgetData(context.loadCachedMonthSource(id, initialCenter, stamp), initialCenter, stamp) provideContent { val index = currentState(MONTH_INDEX_KEY) ?: currentMonthIndex(systemZone()) // Reloads when paging leaves the loaded window, and on every redraw, // which a running session would otherwise never see (#420). val center = monthWindowCenter(index) val currentStamp = currentRefreshStamp() - val source = widgetData.current(center, currentStamp) { - context.loadMonthWidgetSource(id, center, currentStamp) + val loaded = widgetData.current(center, currentStamp) { + context.loadCachedMonthSource(id, center, currentStamp) } + val source = loaded.content // A jump out of the loaded window keeps the last month it covers // until the reload lands, rather than drawing an empty grid. var lastShown by remember { mutableIntStateOf(index) } @@ -153,13 +158,24 @@ class MonthWidget : GlanceAppWidget() { else -> index.coerceIn(source.months) } SideEffect { lastShown = shown } - CalendulaGlanceTheme { - MonthWidgetBody(source = source, index = shown) - } + MonthWidgetContent( + source = source, + index = shown, + appearance = currentState().widgetAppearance, + appTheme = loaded.appTheme, + ) } } } +/** The whole month widget showing month [index], shared with its config screen's preview (#292). */ +@Composable +internal fun MonthWidgetContent(source: MonthWidgetSource, index: Int, appearance: WidgetAppearance, appTheme: ThemeMode) { + CalendulaWidgetRoot(appearance, appTheme) { + MonthWidgetBody(source = source, index = index) + } +} + /** * Step the displayed month by the `delta` action parameter (±1). * @@ -209,7 +225,6 @@ private fun MonthWidgetBody(source: MonthWidgetSource, index: Int) { Column( modifier = GlanceModifier .fillMaxSize() - .background(GlanceTheme.colors.surface) .padding(horizontal = GRID_HPADDING, vertical = 6.dp), ) { when (source) { @@ -226,16 +241,19 @@ private fun MonthWidgetBody(source: MonthWidgetSource, index: Int) { MonthHeader(label = monthLabel(ym, source.today.year)) Spacer(GlanceModifier.height(2.dp)) - WeekdayHeader(weekStart = source.weekStart, colW = colW) - layoutWeeks(weeks, source.today).forEach { layout -> - WeekRow( - layout = layout, - currentMonth = ym.month, - today = source.today, - soften = source.soften, - colW = colW, - modifier = GlanceModifier.defaultWeight(), - ) + // One backing for the whole grid on a transparent widget (#292). + Column(modifier = GlanceModifier.fillMaxWidth().defaultWeight().wallpaperBacking()) { + WeekdayHeader(weekStart = source.weekStart, colW = colW) + layoutWeeks(weeks, source.today).forEach { layout -> + WeekRow( + layout = layout, + currentMonth = ym.month, + today = source.today, + soften = source.soften, + colW = colW, + modifier = GlanceModifier.defaultWeight(), + ) + } } } } @@ -246,7 +264,7 @@ private fun MonthWidgetBody(source: MonthWidgetSource, index: Int) { private fun MonthHeader(label: String) { val context = LocalContext.current Row( - modifier = GlanceModifier.fillMaxWidth(), + modifier = GlanceModifier.fillMaxWidth().wallpaperBacking(), verticalAlignment = Alignment.CenterVertically, ) { HeaderIcon( @@ -470,14 +488,14 @@ private fun SpanBar(event: EventInstance, soften: Boolean, width: Dp) { modifier = GlanceModifier .fillMaxSize() .cornerRadius(4.dp) - .background(ColorProvider(day = dayFill, night = nightFill)), + .background(themedColor(day = dayFill, night = nightFill)), contentAlignment = Alignment.CenterStart, ) { Text( text = event.title.ifBlank { context.getString(R.string.event_untitled) }, maxLines = 1, style = TextStyle( - color = ColorProvider(day = eventInk(dayFill), night = eventInk(nightFill)), + color = themedColor(day = eventInk(dayFill), night = eventInk(nightFill)), fontSize = 9.sp, textDecoration = glanceDeclinedDecoration(event.isDeclined), ), @@ -520,6 +538,7 @@ private fun PermissionMessage() { fontSize = 14.sp, textAlign = TextAlign.Center, ), + modifier = GlanceModifier.wallpaperBacking().padding(horizontal = 12.dp, vertical = 8.dp), ) } } diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/widget/month/MonthWidgetReceiver.kt b/app/src/main/java/de/jeanlucmakiola/calendula/widget/month/MonthWidgetReceiver.kt index 9686a29..7df07dc 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/widget/month/MonthWidgetReceiver.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/widget/month/MonthWidgetReceiver.kt @@ -7,6 +7,7 @@ import androidx.glance.appwidget.GlanceAppWidget import androidx.glance.appwidget.GlanceAppWidgetReceiver import de.jeanlucmakiola.calendula.widget.syncWidgetRollover import de.jeanlucmakiola.floret.glance.reloadOnUpdate +import de.jeanlucmakiola.floret.glance.restoreState /** * Host-facing receiver for the month widget. Declared in the manifest with the @@ -30,6 +31,11 @@ class MonthWidgetReceiver : GlanceAppWidgetReceiver() { syncWidgetRollover(context) } + /** A backup restore or device transfer renumbered the widgets: keep each one's settings (#292). */ + override fun onRestored(context: Context, oldWidgetIds: IntArray, newWidgetIds: IntArray) { + restoreState(context, oldWidgetIds, newWidgetIds) + } + /** * The `updatePeriodMillis` backstop is the one wake-up the *system* still * owns, so it doubles as the alarm's self-heal: anything that drops a diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 0ef6379..b778d26 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -441,6 +441,21 @@ Previous month Next month Today + Background + Solid + Tinted + Transparent + Follow app + Content + Time range + Text size + Couldn\'t read this widget\'s settings. + Preview unavailable + Couldn\'t save the widget. Try again. + + %1$s %2$d + + %1$s · %2$s Calendars @@ -554,7 +569,7 @@ Event text %1$d%% Always show today - Keep today at the top of the agenda and its widget, even once nothing is left today. + Keep today at the top of the agenda, even once nothing is left today. Each widget has its own setting. Range bar Show a bar at the top of the agenda naming the dates shown, with a button to switch the range for the session Today @@ -645,12 +660,15 @@ Default fields and behaviour Reminders and delivery Contact birthdays & anniversaries - Agenda widget, Quick Settings tile + Your widgets, Quick Settings tile Export, import, automatic backup Widgets & tiles - Settings for the home-screen widgets and the Quick Settings tile. Add a widget by long-pressing your home screen. + Each widget has its own look and options. Tap one below, or long-press it on your home screen and choose Edit. Add a widget by long-pressing your home screen. + Each widget has its own look and options. Tap one below to change it. Add a widget by long-pressing your home screen. + On your home screen + No Calendula widgets on your home screen yet. Backup & restore diff --git a/app/src/main/res/values/themes.xml b/app/src/main/res/values/themes.xml index fed97fa..a161487 100644 --- a/app/src/main/res/values/themes.xml +++ b/app/src/main/res/values/themes.xml @@ -7,4 +7,10 @@ @color/window_background true + + + diff --git a/app/src/main/res/xml/appwidget_info_agenda.xml b/app/src/main/res/xml/appwidget_info_agenda.xml index 12abbe3..b8c707c 100644 --- a/app/src/main/res/xml/appwidget_info_agenda.xml +++ b/app/src/main/res/xml/appwidget_info_agenda.xml @@ -8,6 +8,8 @@ android:minResizeHeight="110dp" android:resizeMode="horizontal|vertical" android:widgetCategory="home_screen" + android:configure="de.jeanlucmakiola.calendula.widget.config.WidgetConfigActivity" + android:widgetFeatures="reconfigurable" android:description="@string/widget_agenda_label" android:updatePeriodMillis="1800000" android:previewLayout="@layout/widget_preview_agenda" diff --git a/app/src/main/res/xml/appwidget_info_month.xml b/app/src/main/res/xml/appwidget_info_month.xml index cfa7acb..075aa51 100644 --- a/app/src/main/res/xml/appwidget_info_month.xml +++ b/app/src/main/res/xml/appwidget_info_month.xml @@ -8,6 +8,8 @@ android:minResizeHeight="150dp" android:resizeMode="horizontal|vertical" android:widgetCategory="home_screen" + android:configure="de.jeanlucmakiola.calendula.widget.config.WidgetConfigActivity" + android:widgetFeatures="reconfigurable" android:description="@string/widget_month_label" android:updatePeriodMillis="1800000" android:previewLayout="@layout/widget_preview_month" diff --git a/app/src/test/java/de/jeanlucmakiola/calendula/data/prefs/SettingsPrefsTest.kt b/app/src/test/java/de/jeanlucmakiola/calendula/data/prefs/SettingsPrefsTest.kt index 44101d1..74a425b 100644 --- a/app/src/test/java/de/jeanlucmakiola/calendula/data/prefs/SettingsPrefsTest.kt +++ b/app/src/test/java/de/jeanlucmakiola/calendula/data/prefs/SettingsPrefsTest.kt @@ -165,15 +165,12 @@ class SettingsPrefsTest { } @Test - fun `agenda ranges default to month and round-trip independently`(@TempDir tempDir: Path) = runTest { + fun `agenda screen range defaults to month and round-trips`(@TempDir tempDir: Path) = runTest { val prefs = testSettings(tempDir) assertThat(prefs.agendaScreenRange.first()).isEqualTo(AgendaRange.Month) - assertThat(prefs.agendaWidgetRange.first()).isEqualTo(AgendaRange.Month) prefs.setAgendaScreenRange(AgendaRange.Custom(14)) - prefs.setAgendaWidgetRange(AgendaRange.Week) assertThat(prefs.agendaScreenRange.first()).isEqualTo(AgendaRange.Custom(14)) - assertThat(prefs.agendaWidgetRange.first()).isEqualTo(AgendaRange.Week) } @Test diff --git a/floret-kit b/floret-kit index 96509d0..6dc7bc5 160000 --- a/floret-kit +++ b/floret-kit @@ -1 +1 @@ -Subproject commit 96509d0f9d6bd8b8cea65fbe06b93d2ae6088b55 +Subproject commit 6dc7bc50c5509d50a2e69cf34430640c90dd33bd From 6da0648b777db293cc1ac6e6b98815a099ada23b Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Fri, 23 Oct 2026 14:51:29 +0200 Subject: [PATCH 09/24] Widget redesign: agenda date pills and tinted day cards, month labels, pill headers, true-size config preview (#71, #90) Drops the agenda refresh button (redraws are automatic since #420). Caps the config preview at Glance's 10-row Column limit, which made it fail with busy agendas (#292). --- .../calendula/widget/agenda/AgendaScale.kt | 89 +++-- .../calendula/widget/agenda/AgendaWidget.kt | 328 +++++++++--------- .../calendula/widget/month/MonthGrid.kt | 11 +- .../calendula/widget/month/MonthWidget.kt | 90 ++--- .../main/res/drawable/ic_widget_refresh.xml | 6 - app/src/main/res/drawable/ic_widget_today.xml | 6 - .../widget/agenda/AgendaScaleTest.kt | 51 ++- floret-kit | 2 +- 8 files changed, 270 insertions(+), 313 deletions(-) delete mode 100644 app/src/main/res/drawable/ic_widget_refresh.xml delete mode 100644 app/src/main/res/drawable/ic_widget_today.xml diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/widget/agenda/AgendaScale.kt b/app/src/main/java/de/jeanlucmakiola/calendula/widget/agenda/AgendaScale.kt index 3414741..8b07067 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/widget/agenda/AgendaScale.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/widget/agenda/AgendaScale.kt @@ -6,17 +6,11 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import de.jeanlucmakiola.calendula.widget.WidgetSize -/** - * Horizontal layout constants for an agenda event row. These don't scale with the - * size step, since a wider stripe or gap would eat title width, but [TEXT_INDENT] - * is derived from them so non-event text can't drift out of alignment. - */ -internal val ROW_H_PAD = 4.dp -internal val STRIPE_WIDTH = 5.dp -internal val STRIPE_GAP = 10.dp +/** Horizontal inset of a row's text inside its card; doesn't scale, so it never eats title width. */ +internal val ROW_H_PAD = 12.dp -/** Left indent that lines non-event text up with the event title column. */ -internal val TEXT_INDENT = ROW_H_PAD + STRIPE_WIDTH + STRIPE_GAP +/** Space between the date-pill gutter and a day's card. */ +internal val GUTTER_GAP = 6.dp /** * Every size the agenda widget varies by [WidgetSize]. Values that genuinely @@ -24,90 +18,91 @@ internal val TEXT_INDENT = ROW_H_PAD + STRIPE_WIDTH + STRIPE_GAP * constants rather than routing through here. */ internal data class AgendaMetrics( - val title: TextUnit, // header "Upcoming" - val dayHeader: TextUnit, // day label ("Today · …") + val pillWeekday: TextUnit, // date pill weekday ("Thu") + val pillDay: TextUnit, // date pill day number val eventTitle: TextUnit, // event title line val eventTime: TextUnit, // time/location line val placeholder: TextUnit, // "no more events today" (#35) val message: TextUnit, // empty/permission centred message - val stripeH: Dp, // coloured event stripe height - val iconImage: Dp, // header action icon glyph - val iconBox: Dp, // header action touch target + val contentH: Dp, // an event row's two text lines + val pillWidth: Dp, // date pill / gutter width val rowVPad: Dp, // event row vertical padding - val dayHeaderTopPad: Dp, // space above a day header + val dayGap: Dp, // space between two days' cards + val monthLabel: TextUnit, // month name above a new month's first day ) { + /** One event row's height, shared by the date pill beside it and the placeholder row. */ + val rowHeight: Dp get() = contentH + rowVPad * 2 + /** - * Resolves the stripe height against the user's system font scale: the stripe - * is a [Dp] while the lines beside it are `sp`, so without this the text - * outgrows it at large font settings. + * Resolves the row content height and pill width against the user's system font + * scale: both are [Dp] while the text in and beside them is `sp`, so without + * this the text outgrows them at large font settings. */ fun scaledForFont(fontScale: Float): AgendaMetrics = - if (fontScale == 1f) this else copy(stripeH = stripeH * fontScale) + if (fontScale == 1f) this else copy(contentH = contentH * fontScale, pillWidth = pillWidth * fontScale) } /* - * Type sizes are anchored to the Material 3 type scale, with two deviations: + * Type sizes are anchored to the Material 3 type scale, with one deviation: * - * ‡ SMALL's 13sp day header is off-scale, held there so SMALL reproduces the - * widget's original constants verbatim (#51). * † Above Title Medium the M3 scale jumps 16 → 22 → 24, too coarse for four * steps; interpolated values are used where a role would force a ≥1.4x jump. */ private val SMALL_METRICS = AgendaMetrics( - title = 16.sp, // M3 Title Medium - dayHeader = 13.sp, // ‡ off-scale, holds the #51 baseline + pillWeekday = 11.sp, + pillDay = 16.sp, eventTitle = 14.sp, // M3 Body Medium eventTime = 12.sp, // M3 Body Small placeholder = 14.sp, // M3 Body Medium message = 14.sp, // M3 Body Medium - stripeH = 36.dp, - iconImage = 22.dp, - iconBox = 40.dp, + contentH = 36.dp, + pillWidth = 40.dp, rowVPad = 4.dp, - dayHeaderTopPad = 10.dp, + dayGap = 8.dp, + monthLabel = 12.sp, ) private val MEDIUM_METRICS = AgendaMetrics( - title = 18.sp, // † - dayHeader = 14.sp, // M3 Title Small + pillWeekday = 12.sp, + pillDay = 18.sp, eventTitle = 16.sp, // M3 Body Large eventTime = 14.sp, // M3 Body Medium placeholder = 16.sp, // M3 Body Large message = 16.sp, // M3 Body Large - stripeH = 40.dp, - iconImage = 24.dp, - iconBox = 44.dp, + contentH = 40.dp, + pillWidth = 44.dp, rowVPad = 5.dp, - dayHeaderTopPad = 11.dp, + dayGap = 9.dp, + monthLabel = 14.sp, ) private val LARGE_METRICS = AgendaMetrics( - title = 20.sp, // † - dayHeader = 16.sp, // M3 Title Medium + pillWeekday = 12.sp, + pillDay = 20.sp, eventTitle = 18.sp, // † eventTime = 14.sp, // M3 Body Medium — the secondary line steps more slowly placeholder = 18.sp, // † on purpose, so the title keeps its message = 18.sp, // † lead and the hierarchy survives. - stripeH = 46.dp, - iconImage = 26.dp, - iconBox = 48.dp, + contentH = 46.dp, + pillWidth = 48.dp, rowVPad = 6.dp, - dayHeaderTopPad = 12.dp, + dayGap = 10.dp, + monthLabel = 14.sp, ) private val EXTRA_LARGE_METRICS = AgendaMetrics( - title = 22.sp, // M3 Title Large - dayHeader = 18.sp, // † + pillWeekday = 14.sp, + pillDay = 22.sp, eventTitle = 20.sp, // † eventTime = 16.sp, // M3 Body Large placeholder = 20.sp, // † message = 20.sp, // † - stripeH = 52.dp, - iconImage = 28.dp, - iconBox = 52.dp, + contentH = 52.dp, + pillWidth = 52.dp, rowVPad = 8.dp, - dayHeaderTopPad = 14.dp, + dayGap = 12.dp, + monthLabel = 16.sp, ) /** Indexed by [WidgetSize.ordinal] so lookup allocates nothing per recomposition. */ 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 1695baa..3a86aea 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 @@ -6,18 +6,13 @@ import android.content.Context import androidx.compose.runtime.Composable import androidx.compose.ui.graphics.Color import androidx.compose.ui.unit.dp -import androidx.glance.ColorFilter import androidx.glance.GlanceId import androidx.glance.GlanceModifier import androidx.glance.GlanceTheme -import androidx.glance.Image import androidx.glance.ImageProvider -import androidx.glance.action.ActionParameters import androidx.glance.action.clickable import androidx.glance.appwidget.GlanceAppWidget import androidx.glance.appwidget.SizeMode -import androidx.glance.appwidget.action.ActionCallback -import androidx.glance.appwidget.action.actionRunCallback import androidx.glance.appwidget.action.actionStartActivity import androidx.glance.appwidget.cornerRadius import androidx.glance.appwidget.lazy.LazyColumn @@ -36,6 +31,12 @@ import androidx.glance.layout.height import androidx.glance.layout.padding import androidx.glance.layout.size import androidx.glance.layout.width +import androidx.glance.semantics.contentDescription +import androidx.glance.semantics.semantics +import androidx.glance.text.TextAlign +import androidx.glance.unit.ColorProvider +import de.jeanlucmakiola.floret.glance.WidgetHeader +import de.jeanlucmakiola.floret.glance.WidgetHeaderAction import androidx.glance.text.FontWeight import androidx.glance.text.Text import androidx.glance.text.TextStyle @@ -49,10 +50,12 @@ import de.jeanlucmakiola.calendula.ui.agenda.agendaTimeLabel import de.jeanlucmakiola.calendula.ui.agenda.anchorTodayIfMissing import de.jeanlucmakiola.calendula.ui.common.CalendarView import de.jeanlucmakiola.calendula.ui.common.EventDimAlpha +import de.jeanlucmakiola.calendula.ui.common.formatCalendarTitle import de.jeanlucmakiola.calendula.ui.common.formatTimeOfDay import de.jeanlucmakiola.floret.locale.localizedDateFormatter -import de.jeanlucmakiola.calendula.ui.common.eventAccent import de.jeanlucmakiola.calendula.ui.common.eventFill +import de.jeanlucmakiola.calendula.ui.common.eventInk +import de.jeanlucmakiola.calendula.ui.common.SECONDARY_INK_ALPHA import de.jeanlucmakiola.calendula.widget.AgendaWidgetData import de.jeanlucmakiola.calendula.widget.WidgetSize import de.jeanlucmakiola.calendula.widget.CalendulaWidgetRoot @@ -72,20 +75,20 @@ import androidx.glance.currentState import de.jeanlucmakiola.floret.glance.WidgetData import de.jeanlucmakiola.floret.glance.currentRefreshStamp import de.jeanlucmakiola.floret.glance.refreshStamp -import de.jeanlucmakiola.floret.glance.updateWithRefresh import de.jeanlucmakiola.calendula.widget.systemZone import de.jeanlucmakiola.calendula.widget.today import kotlinx.datetime.DateTimeUnit import kotlinx.datetime.LocalDate import kotlinx.datetime.TimeZone import kotlinx.datetime.plus +import kotlinx.datetime.toJavaDayOfWeek import kotlinx.datetime.toLocalDateTime import kotlin.time.Instant import java.util.Locale /** * "Upcoming" agenda widget — a continuously scrolling list of the next ~30 days - * of events grouped under day headers (the Google "Schedule" widget model). + * of events, each day one card of grouped rows with its date pill beside it. * Reuses the app's [groupAgendaDays] grouping so it matches the in-app agenda. */ class AgendaWidget : GlanceAppWidget() { @@ -131,15 +134,8 @@ internal fun AgendaWidgetContent( } } -/** 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) { - AgendaWidget().updateWithRefresh(context.applicationContext, glanceId) - } -} +/** Rows a preview draws: Glance's cap on a Column's children, still more than a placed widget shows. */ +private const val PREVIEW_ROWS = 10 /** * Upper bound on rows handed to the [LazyColumn], so the serialized RemoteViews @@ -150,19 +146,18 @@ class RefreshAgendaAction : ActionCallback { */ private const val MAX_AGENDA_ROWS = 100 -/** Flat row model so the [LazyColumn] can mix day headers and events. */ +/** Flat row model: one [LazyColumn] item per event or placeholder, a day's first one carrying its date pill. */ private sealed interface 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). */ + /** "Nothing left today" line for an anchored, event-less today (#35). */ data class Placeholder(override val date: LocalDate) : AgendaRow } @Composable private fun AgendaWidgetBody(data: AgendaWidgetData, preview: Boolean) { - // The stripe is re-resolved against the system font scale so it tracks the + // The row height 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)?.textSize ?: WidgetSize.SMALL val fontScale = androidx.glance.LocalContext.current.resources.configuration.fontScale @@ -172,8 +167,8 @@ private fun AgendaWidgetBody(data: AgendaWidgetData, preview: Boolean) { .fillMaxSize() .padding(horizontal = 8.dp, vertical = 6.dp), ) { - AgendaHeader(metrics) - Spacer(GlanceModifier.height(4.dp)) + AgendaHeader() + Spacer(GlanceModifier.height(6.dp)) when (data) { AgendaWidgetData.NeedsPermission -> WidgetMessage(R.string.widget_needs_permission, metrics) is AgendaWidgetData.Ready -> AgendaRows(data = data, metrics = metrics, preview = preview) @@ -212,33 +207,47 @@ private fun AgendaRows( } else { val rows = buildList { visibleDays.forEach { day -> - add(AgendaRow.Header(day.date, data.today)) if (day.events.isEmpty()) { add(AgendaRow.Placeholder(day.date)) } else { day.events.forEach { add(AgendaRow.Event(day.date, it)) } } } - } - // Bound the payload, then drop a day header the cut left - // stranded with nothing under it. - .take(MAX_AGENDA_ROWS) - .dropLastWhile { it is AgendaRow.Header } - // 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 + }.take(MAX_AGENDA_ROWS) + // Each day is one card of grouped rows (#71), with its date pill + // beside the first row. + val positions = dayPositions(rows) + val cardColor = agendaCardColor() 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) + val position = positions[index] + val first = position == Position.Top || position == Position.Alone + val dayRow: @Composable (GlanceModifier) -> Unit = { modifier -> + Row(modifier = modifier.fillMaxWidth()) { + Box(modifier = GlanceModifier.width(metrics.pillWidth)) { + if (first) DatePill(row.date, data.today, cardColor, metrics) + } + Spacer(GlanceModifier.width(GUTTER_GAP)) + Box(modifier = GlanceModifier.defaultWeight()) { + val rowColor = when (row) { + is AgendaRow.Event -> row.fill(data).let { themedColor(day = it.day, night = it.night) } + is AgendaRow.Placeholder -> cardColor + } + WidgetGroupedRow(position = position, color = rowColor) { + AgendaRowContent(row, data, metrics) + } + } + } + } + val gap = GlanceModifier.padding(top = if (first && index > 0) metrics.dayGap else 0.dp) + // The pills carry no month, so a new month gets its name above its first day. + if (first && index > 0 && row.date.month != rows[index - 1].date.month) { + Column(modifier = gap.fillMaxWidth()) { + MonthLabel(row.date, data.today, metrics) + dayRow(GlanceModifier) } } else { - AgendaRowContent(row, data, metrics) + dayRow(gap) } } if (preview) { @@ -267,156 +276,159 @@ private fun dayPositions(rows: List): List = buildList { @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, + fill = row.fill(data), is24Hour = data.is24Hour, - dimmed = data.pastDisplay == PastEventDisplay.DIM && row.event.hasEnded(data.now), metrics = metrics, ) } } @Composable -private fun AgendaHeader(metrics: AgendaMetrics) { +private fun AgendaHeader() { val context = androidx.glance.LocalContext.current - Row( - 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 + // (issue #20) — a null target resolves to the default home in the host. + WidgetHeader( + title = context.getString(R.string.widget_agenda_title), + titleAction = actionStartActivity(MainActivity.openViewIntent(context, view = null)), + modifier = GlanceModifier.padding(start = 4.dp), ) { - // Tap the "Upcoming" title to open the app on the user's default view - // (issue #20) — a null target resolves to the default home in the host. - Text( - text = context.getString(R.string.widget_agenda_title), - style = TextStyle( - color = GlanceTheme.colors.primary, - fontSize = metrics.title, - fontWeight = FontWeight.Medium, - ), - modifier = GlanceModifier - .defaultWeight() - .clickable( - actionStartActivity(MainActivity.openViewIntent(context, view = null)), - ), - ) - IconButton( - resId = R.drawable.ic_widget_refresh, - contentDescription = context.getString(R.string.widget_refresh), - onClick = GlanceModifier.clickable(actionRunCallback()), - metrics = metrics, - ) - IconButton( - resId = R.drawable.ic_widget_add, + WidgetHeaderAction( + icon = ImageProvider(R.drawable.ic_widget_add), contentDescription = context.getString(R.string.widget_new_event), - onClick = GlanceModifier.clickable( - actionStartActivity( - MainActivity.openCreateIntent(context, today(systemZone())), - ), - ), - metrics = metrics, + action = actionStartActivity(MainActivity.openCreateIntent(context, today(systemZone()))), ) } } +/** Cards sit one tone off the root fill, and stay opaque over a wallpaper (#292). */ @Composable -private fun IconButton( - resId: Int, - contentDescription: String, - onClick: GlanceModifier, - metrics: AgendaMetrics, -) { - Box( - modifier = GlanceModifier.size(metrics.iconBox).then(onClick), - contentAlignment = Alignment.Center, - ) { - Image( - provider = ImageProvider(resId), - contentDescription = contentDescription, - colorFilter = ColorFilter.tint(GlanceTheme.colors.onSurfaceVariant), - modifier = GlanceModifier.size(metrics.iconImage), - ) - } +private fun agendaCardColor(): ColorProvider = when (LocalWidgetBackground.current) { + WidgetBackground.SOLID -> GlanceTheme.colors.surfaceVariant + WidgetBackground.TINTED, WidgetBackground.TRANSPARENT -> GlanceTheme.colors.widgetBackground } +/** The month name ("November", with its year outside the current one) above a new month's first day. */ @Composable -private fun DayHeaderRow(date: LocalDate, today: LocalDate, metrics: AgendaMetrics) { - val context = androidx.glance.LocalContext.current +private fun MonthLabel(date: LocalDate, today: LocalDate, metrics: AgendaMetrics) { Text( - text = agendaDayLabel(context, date, today), + text = formatCalendarTitle( + date = java.time.LocalDate.of(date.year, date.month.ordinal + 1, 1), + locale = Locale.getDefault(), + currentYear = today.year, + skeleton = "LLLL", + ), + maxLines = 1, style = TextStyle( - color = if (date == today) GlanceTheme.colors.primary - else GlanceTheme.colors.onSurfaceVariant, - fontSize = metrics.dayHeader, + color = GlanceTheme.colors.onSurfaceVariant, + fontSize = metrics.monthLabel, fontWeight = FontWeight.Medium, ), modifier = GlanceModifier - .fillMaxWidth() - .padding( - start = 8.dp, - end = 8.dp, - top = metrics.dayHeaderTopPad, - bottom = metrics.rowVPad, - ) - .clickable( - actionStartActivity( - MainActivity.openDateIntent(context, date, CalendarView.Agenda), - ), - ), + .padding(bottom = 4.dp) + .wallpaperBacking(radius = 8.dp) + .padding(horizontal = 6.dp, vertical = 2.dp), ) } -/** - * "No more events today" under an anchored, event-less today (#35). Kept plain — - * a muted line indented to the event titles — since the widget's event rows are - * plain too (a stripe + text, not cards), so a card here would look out of place. - */ +/** A day's weekday and number in a rounded pill, filled for today; opens that day in the app. */ +@Composable +private fun DatePill(date: LocalDate, today: LocalDate, cardColor: ColorProvider, metrics: AgendaMetrics) { + val context = androidx.glance.LocalContext.current + val isToday = date == today + val container = if (isToday) GlanceTheme.colors.primary else cardColor + val content = if (isToday) GlanceTheme.colors.onPrimary else GlanceTheme.colors.onSurface + val label = agendaDayLabel(context, date, today) + Column( + modifier = GlanceModifier + .width(metrics.pillWidth) + .height(metrics.rowHeight) + .cornerRadius(metrics.pillWidth / 2) + .background(container) + .clickable(actionStartActivity(MainActivity.openDateIntent(context, date, CalendarView.Agenda))) + .semantics { contentDescription = label }, + horizontalAlignment = Alignment.CenterHorizontally, + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = weekdayShort(date), + maxLines = 1, + style = TextStyle(color = content, fontSize = metrics.pillWeekday, textAlign = TextAlign.Center), + ) + Text( + text = date.day.toString(), + maxLines = 1, + style = TextStyle( + color = content, + fontSize = metrics.pillDay, + fontWeight = FontWeight.Medium, + textAlign = TextAlign.Center, + ), + ) + } +} + +/** "No more events today" in an anchored, event-less today's card (#35), as tall as the pill beside it. */ @Composable private fun PlaceholderRow(date: LocalDate, metrics: AgendaMetrics) { val context = androidx.glance.LocalContext.current - Text( - text = context.getString(R.string.agenda_no_more_today), - style = TextStyle(color = GlanceTheme.colors.onSurfaceVariant, fontSize = metrics.placeholder), + Box( modifier = GlanceModifier .fillMaxWidth() - .padding( - start = TEXT_INDENT, - end = 8.dp, - top = 2.dp, - bottom = metrics.rowVPad + 2.dp, - ) + .height(metrics.rowHeight) + .padding(horizontal = ROW_H_PAD) .clickable( actionStartActivity( MainActivity.openDateIntent(context, date, CalendarView.Agenda), ), ), - ) + contentAlignment = Alignment.CenterStart, + ) { + Text( + text = context.getString(R.string.agenda_no_more_today), + maxLines = 1, + style = TextStyle(color = GlanceTheme.colors.onSurfaceVariant, fontSize = metrics.placeholder), + ) + } +} + +/** An event row's light and dark fill: its calendar's container tone, faded when the row is dimmed. */ +private data class EventFill(val day: Color, val night: Color) + +private fun AgendaRow.Event.fill(data: AgendaWidgetData.Ready): EventFill { + val dimmed = data.pastDisplay == PastEventDisplay.DIM && event.hasEnded(data.now) + fun tone(dark: Boolean) = eventFill(event.color, dark, data.soften).let { + if (dimmed) it.copy(alpha = EventDimAlpha) else it + } + return EventFill(day = tone(dark = false), night = tone(dark = true)) } @Composable private fun EventRow( event: EventInstance, day: LocalDate, - soften: Boolean, + fill: EventFill, is24Hour: Boolean, - dimmed: Boolean, metrics: AgendaMetrics, ) { val context = androidx.glance.LocalContext.current 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. - fun stripe(dark: Boolean) = eventAccent(event.color, dark, soften).let { - if (dimmed) it.copy(alpha = EventDimAlpha) else it - } - val stripeColor = themedColor(day = stripe(dark = false), night = stripe(dark = true)) - val titleColor = if (dimmed) GlanceTheme.colors.onSurfaceVariant else GlanceTheme.colors.onSurface - Row( + // Ink is picked against the opaque fill, then carries the dim along with it. + fun ink(fill: Color, alpha: Float) = eventInk(fill.copy(alpha = 1f), alpha * fill.alpha) + val titleInk = themedColor(day = ink(fill.day, 1f), night = ink(fill.night, 1f)) + val timeInk = themedColor( + day = ink(fill.day, SECONDARY_INK_ALPHA), + night = ink(fill.night, SECONDARY_INK_ALPHA), + ) + Column( modifier = GlanceModifier .fillMaxWidth() - .padding(horizontal = ROW_H_PAD, vertical = metrics.rowVPad) + .height(metrics.rowHeight) + .padding(horizontal = ROW_H_PAD) .clickable( actionStartActivity( MainActivity.openEventIntent( @@ -430,30 +442,21 @@ private fun EventRow( ), verticalAlignment = Alignment.CenterVertically, ) { - Box( - modifier = GlanceModifier - .width(STRIPE_WIDTH) - .height(metrics.stripeH) - .cornerRadius(3.dp) - .background(stripeColor), - ) {} - Spacer(GlanceModifier.width(STRIPE_GAP)) - Column(modifier = GlanceModifier.defaultWeight()) { - Text( - text = title, - maxLines = 1, - style = TextStyle( - color = titleColor, - fontSize = metrics.eventTitle, - textDecoration = glanceDeclinedDecoration(event.isDeclined), - ), - ) - Text( - text = eventTimeSummary(context, event, day, is24Hour), - maxLines = 1, - style = TextStyle(color = GlanceTheme.colors.onSurfaceVariant, fontSize = metrics.eventTime), - ) - } + Text( + text = title, + maxLines = 1, + style = TextStyle( + color = titleInk, + fontSize = metrics.eventTitle, + fontWeight = FontWeight.Medium, + textDecoration = glanceDeclinedDecoration(event.isDeclined), + ), + ) + Text( + text = eventTimeSummary(context, event, day, is24Hour), + maxLines = 1, + style = TextStyle(color = timeInk, fontSize = metrics.eventTime), + ) } } @@ -489,6 +492,9 @@ private fun agendaDayLabel(context: Context, date: LocalDate, today: LocalDate): return if (relative != null) "$relative · $formatted" else formatted } +private fun weekdayShort(date: LocalDate): String = + date.dayOfWeek.toJavaDayOfWeek().getDisplayName(java.time.format.TextStyle.SHORT, Locale.getDefault()) + private fun eventTimeSummary( context: Context, event: EventInstance, diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/widget/month/MonthGrid.kt b/app/src/main/java/de/jeanlucmakiola/calendula/widget/month/MonthGrid.kt index 01d70b3..c4d7828 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/widget/month/MonthGrid.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/widget/month/MonthGrid.kt @@ -32,11 +32,14 @@ internal class ViewIds(sdkInt: Int = Build.VERSION.SDK_INT) { /** * `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. + * Column, the header row with its clickable title Box, title Text and (on a + * transparent widget) the spacer after the title pill, two + * sized clickable icon pills with their sized icons, the sized clickable + * today pill with its Text and the two sized action gaps, the spacer, the + * grid's Column and the weekday row of sized labels. */ - val fixed = plain + plain + plain + 3 * (clickableSized + sized) + clickablePlain + sized + plain + plain + 7 * sized + val fixed = plain + plain + plain + clickablePlain + plain + plain + 2 * (clickableSized + sized) + + (clickableSized + plain) + 2 * sized + sized + plain + plain + 7 * sized } /** A cell [cols] columns wide holding [item], or a merged gap where [item] is null. */ diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/widget/month/MonthWidget.kt b/app/src/main/java/de/jeanlucmakiola/calendula/widget/month/MonthWidget.kt index c0443ad..b89270e 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 @@ -13,11 +13,9 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.datastore.preferences.core.Preferences import androidx.datastore.preferences.core.intPreferencesKey -import androidx.glance.ColorFilter import androidx.glance.GlanceId import androidx.glance.GlanceModifier import androidx.glance.GlanceTheme -import androidx.glance.Image import androidx.glance.ImageProvider import androidx.glance.LocalContext import androidx.glance.LocalSize @@ -48,6 +46,11 @@ import androidx.glance.layout.padding import androidx.glance.layout.size import androidx.glance.layout.width import androidx.glance.state.PreferencesGlanceStateDefinition +import de.jeanlucmakiola.floret.glance.WidgetActionStyle +import de.jeanlucmakiola.floret.glance.WidgetHeader +import de.jeanlucmakiola.floret.glance.WidgetHeaderAction +import de.jeanlucmakiola.floret.glance.WidgetHeaderActionGap +import de.jeanlucmakiola.floret.glance.WidgetHeaderTextAction import androidx.glance.text.FontWeight import androidx.glance.text.Text import androidx.glance.text.TextAlign @@ -229,7 +232,7 @@ private fun MonthWidgetBody(source: MonthWidgetSource, index: Int) { ) { when (source) { MonthWidgetSource.NeedsPermission -> { - MonthHeader(label = "Calendula") + MonthHeader(label = "Calendula", today = today(systemZone())) PermissionMessage() } is MonthWidgetSource.Ready -> { @@ -239,8 +242,8 @@ private fun MonthWidgetBody(source: MonthWidgetSource, index: Int) { val colW = (LocalSize.current.width - GRID_HPADDING * 2) / 7 val weeks = layoutMonthWeeks(ym, source.weekStart, source.instances, zone) - MonthHeader(label = monthLabel(ym, source.today.year)) - Spacer(GlanceModifier.height(2.dp)) + MonthHeader(label = monthLabel(ym, source.today.year), today = source.today) + Spacer(GlanceModifier.height(4.dp)) // One backing for the whole grid on a transparent widget (#292). Column(modifier = GlanceModifier.fillMaxWidth().defaultWeight().wallpaperBacking()) { WeekdayHeader(weekStart = source.weekStart, colW = colW) @@ -261,68 +264,35 @@ private fun MonthWidgetBody(source: MonthWidgetSource, index: Int) { } @Composable -private fun MonthHeader(label: String) { +private fun MonthHeader(label: String, today: LocalDate) { val context = LocalContext.current - Row( - modifier = GlanceModifier.fillMaxWidth().wallpaperBacking(), - verticalAlignment = Alignment.CenterVertically, + // Tap the month/year title to open the app on the month view (issue #18), + // rooted over the default home so backing out returns there. + WidgetHeader( + title = label, + titleAction = actionStartActivity(MainActivity.openViewIntent(context, CalendarView.Month)), + modifier = GlanceModifier.padding(start = 4.dp, bottom = 2.dp), ) { - HeaderIcon( - resId = R.drawable.ic_widget_chevron_left, + WidgetHeaderAction( + icon = ImageProvider(R.drawable.ic_widget_chevron_left), contentDescription = context.getString(R.string.widget_prev_month), - onClick = GlanceModifier.clickable( - actionRunCallback( - actionParametersOf(ShiftMonthAction.deltaKey to -1), - ), - ), + action = actionRunCallback(actionParametersOf(ShiftMonthAction.deltaKey to -1)), + style = WidgetActionStyle.TONAL, ) - // Tap the month/year title to open the app on the month view (issue #18), - // rooted over the default home so backing out returns there. - Text( - text = label, - style = TextStyle( - color = GlanceTheme.colors.primary, - fontSize = 15.sp, - fontWeight = FontWeight.Medium, - textAlign = TextAlign.Center, - ), - modifier = GlanceModifier - .defaultWeight() - .clickable( - actionStartActivity( - MainActivity.openViewIntent(context, CalendarView.Month), - ), - ), - ) - // The "today" button snaps the grid back to the current month in place. - HeaderIcon( - resId = R.drawable.ic_widget_today, + WidgetHeaderActionGap() + // The "today" pill carries today's number like the app icon, and snaps + // the grid back to the current month in place. + WidgetHeaderTextAction( + text = today.day.toString(), contentDescription = context.getString(R.string.widget_today), - onClick = GlanceModifier.clickable(actionRunCallback()), + action = actionRunCallback(), ) - HeaderIcon( - resId = R.drawable.ic_widget_chevron_right, + WidgetHeaderActionGap() + WidgetHeaderAction( + icon = ImageProvider(R.drawable.ic_widget_chevron_right), contentDescription = context.getString(R.string.widget_next_month), - onClick = GlanceModifier.clickable( - actionRunCallback( - actionParametersOf(ShiftMonthAction.deltaKey to 1), - ), - ), - ) - } -} - -@Composable -private fun HeaderIcon(resId: Int, contentDescription: String, onClick: GlanceModifier) { - Box( - modifier = GlanceModifier.size(40.dp).then(onClick), - contentAlignment = Alignment.Center, - ) { - Image( - provider = ImageProvider(resId), - contentDescription = contentDescription, - colorFilter = ColorFilter.tint(GlanceTheme.colors.onSurfaceVariant), - modifier = GlanceModifier.size(20.dp), + action = actionRunCallback(actionParametersOf(ShiftMonthAction.deltaKey to 1)), + style = WidgetActionStyle.TONAL, ) } } diff --git a/app/src/main/res/drawable/ic_widget_refresh.xml b/app/src/main/res/drawable/ic_widget_refresh.xml deleted file mode 100644 index ee48912..0000000 --- a/app/src/main/res/drawable/ic_widget_refresh.xml +++ /dev/null @@ -1,6 +0,0 @@ - - - diff --git a/app/src/main/res/drawable/ic_widget_today.xml b/app/src/main/res/drawable/ic_widget_today.xml deleted file mode 100644 index 075ba88..0000000 --- a/app/src/main/res/drawable/ic_widget_today.xml +++ /dev/null @@ -1,6 +0,0 @@ - - - diff --git a/app/src/test/java/de/jeanlucmakiola/calendula/widget/agenda/AgendaScaleTest.kt b/app/src/test/java/de/jeanlucmakiola/calendula/widget/agenda/AgendaScaleTest.kt index 80d64f9..15804b8 100644 --- a/app/src/test/java/de/jeanlucmakiola/calendula/widget/agenda/AgendaScaleTest.kt +++ b/app/src/test/java/de/jeanlucmakiola/calendula/widget/agenda/AgendaScaleTest.kt @@ -15,25 +15,12 @@ class AgendaScaleTest { // SMALL is the default, so if this fails an agenda widget whose owner never // touched the size setting no longer looks as it did (#51). val m = metricsFor(WidgetSize.SMALL) - assertThat(m.title).isEqualTo(16.sp) - assertThat(m.dayHeader).isEqualTo(13.sp) assertThat(m.eventTitle).isEqualTo(14.sp) assertThat(m.eventTime).isEqualTo(12.sp) assertThat(m.placeholder).isEqualTo(14.sp) assertThat(m.message).isEqualTo(14.sp) - assertThat(m.stripeH).isEqualTo(36.dp) - assertThat(m.iconImage).isEqualTo(22.dp) - assertThat(m.iconBox).isEqualTo(40.dp) + assertThat(m.contentH).isEqualTo(36.dp) assertThat(m.rowVPad).isEqualTo(4.dp) - assertThat(m.dayHeaderTopPad).isEqualTo(10.dp) - } - - @Test - fun `the derived text indent matches the original hardcoded inset`() { - // TEXT_INDENT replaced a hardcoded 19dp; it must still resolve to 19dp or - // day headers and the placeholder line stop aligning with event titles. - assertThat(TEXT_INDENT).isEqualTo(19.dp) - assertThat(TEXT_INDENT).isEqualTo(ROW_H_PAD + STRIPE_WIDTH + STRIPE_GAP) } // --- the ramp ------------------------------------------------------------ @@ -43,17 +30,17 @@ class AgendaScaleTest { val steps = WidgetSize.entries.map(::metricsFor) steps.zipWithNext { small, big -> - assertThat(big.title.value).isAtLeast(small.title.value) - assertThat(big.dayHeader.value).isAtLeast(small.dayHeader.value) + assertThat(big.pillWeekday.value).isAtLeast(small.pillWeekday.value) + assertThat(big.pillDay.value).isAtLeast(small.pillDay.value) assertThat(big.eventTitle.value).isAtLeast(small.eventTitle.value) assertThat(big.eventTime.value).isAtLeast(small.eventTime.value) assertThat(big.placeholder.value).isAtLeast(small.placeholder.value) assertThat(big.message.value).isAtLeast(small.message.value) - assertThat(big.stripeH.value).isAtLeast(small.stripeH.value) - assertThat(big.iconImage.value).isAtLeast(small.iconImage.value) - assertThat(big.iconBox.value).isAtLeast(small.iconBox.value) + assertThat(big.contentH.value).isAtLeast(small.contentH.value) + assertThat(big.pillWidth.value).isAtLeast(small.pillWidth.value) assertThat(big.rowVPad.value).isAtLeast(small.rowVPad.value) - assertThat(big.dayHeaderTopPad.value).isAtLeast(small.dayHeaderTopPad.value) + assertThat(big.dayGap.value).isAtLeast(small.dayGap.value) + assertThat(big.monthLabel.value).isAtLeast(small.monthLabel.value) } } @@ -71,20 +58,28 @@ class AgendaScaleTest { // Guards against a future edit turning "more readable" into "absurd". val base = metricsFor(WidgetSize.SMALL) val top = metricsFor(WidgetSize.EXTRA_LARGE) - assertThat(top.title.value / base.title.value).isLessThan(1.5f) + assertThat(top.pillDay.value / base.pillDay.value).isLessThan(1.5f) assertThat(top.eventTitle.value / base.eventTitle.value).isLessThan(1.5f) } // --- font scale ---------------------------------------------------------- @Test - fun `the stripe tracks the system font scale`() { - // The stripe is Dp, the text beside it is sp: without this the two diverge - // at large accessibility font settings and the stripe under-runs the row. + fun `the row height tracks the system font scale`() { + // The row height is Dp, the text in it is sp: without this the two diverge + // at large accessibility font settings and the text outgrows the row. val m = metricsFor(WidgetSize.SMALL) - assertThat(m.scaledForFont(1f).stripeH).isEqualTo(36.dp) - assertThat(m.scaledForFont(1.3f).stripeH.value).isWithin(0.01f).of(46.8f) - assertThat(m.scaledForFont(0.85f).stripeH.value).isWithin(0.01f).of(30.6f) + assertThat(m.scaledForFont(1f).contentH).isEqualTo(36.dp) + assertThat(m.scaledForFont(1.3f).contentH.value).isWithin(0.01f).of(46.8f) + assertThat(m.scaledForFont(0.85f).contentH.value).isWithin(0.01f).of(30.6f) + } + + @Test + fun `the date pill widens with the system font scale`() { + // The pill's text is sp; a fixed-width pill clips "Wed" / "23" at large font settings. + val m = metricsFor(WidgetSize.SMALL) + assertThat(m.scaledForFont(2f).pillWidth).isEqualTo(m.pillWidth * 2) + assertThat(m.scaledForFont(2f).rowHeight).isEqualTo(m.contentH * 2 + m.rowVPad * 2) } @Test @@ -99,7 +94,7 @@ class AgendaScaleTest { // double-count it. val m = metricsFor(WidgetSize.MEDIUM) val scaled = m.scaledForFont(1.3f) - assertThat(scaled.title).isEqualTo(m.title) + assertThat(scaled.pillDay).isEqualTo(m.pillDay) assertThat(scaled.eventTitle).isEqualTo(m.eventTitle) assertThat(scaled.rowVPad).isEqualTo(m.rowVPad) } diff --git a/floret-kit b/floret-kit index 6dc7bc5..d6b5547 160000 --- a/floret-kit +++ b/floret-kit @@ -1 +1 @@ -Subproject commit 6dc7bc50c5509d50a2e69cf34430640c90dd33bd +Subproject commit d6b55476d1e1d52e9eecffbf9491e2d47ea7e736 From d43a741c9306adc2f03e56caea377c6d81d431fb Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Fri, 23 Oct 2026 15:16:28 +0200 Subject: [PATCH 10/24] Agenda widget: format the month label outside the composable (#71) --- .../calendula/widget/agenda/AgendaWidget.kt | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) 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 3a86aea..66bef32 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 @@ -316,12 +316,7 @@ private fun agendaCardColor(): ColorProvider = when (LocalWidgetBackground.curre @Composable private fun MonthLabel(date: LocalDate, today: LocalDate, metrics: AgendaMetrics) { Text( - text = formatCalendarTitle( - date = java.time.LocalDate.of(date.year, date.month.ordinal + 1, 1), - locale = Locale.getDefault(), - currentYear = today.year, - skeleton = "LLLL", - ), + text = monthName(date, today), maxLines = 1, style = TextStyle( color = GlanceTheme.colors.onSurfaceVariant, @@ -492,6 +487,13 @@ private fun agendaDayLabel(context: Context, date: LocalDate, today: LocalDate): return if (relative != null) "$relative · $formatted" else formatted } +private fun monthName(date: LocalDate, today: LocalDate): String = formatCalendarTitle( + date = java.time.LocalDate.of(date.year, date.month.ordinal + 1, 1), + locale = Locale.getDefault(), + currentYear = today.year, + skeleton = "LLLL", +) + private fun weekdayShort(date: LocalDate): String = date.dayOfWeek.toJavaDayOfWeek().getDisplayName(java.time.format.TextStyle.SHORT, Locale.getDefault()) From d1557825c677a5399da2f9f72cc5c18d52e214da Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Fri, 23 Oct 2026 15:12:18 +0200 Subject: [PATCH 11/24] Month widget: draw the weeks as plain RemoteViews, lanes from the widget's height (#372) Glance's 499 view ids forced busy months down to one or two lanes. The week rows are now one embedded RemoteViews tree, so every event that fits the height gets its bar and only the rest folds into +N. The on-device test now checks the payload instead of the view count. --- ...udgetTest.kt => MonthWidgetPayloadTest.kt} | 33 ++- .../calendula/widget/month/MonthGrid.kt | 102 ++------ .../calendula/widget/month/MonthGridViews.kt | 177 ++++++++++++++ .../calendula/widget/month/MonthWidget.kt | 213 ++--------------- .../main/res/drawable/widget_month_bar_bg.xml | 5 + .../res/drawable/widget_month_today_bg.xml | 4 + .../main/res/layout/widget_month_bar_1.xml | 26 +++ .../main/res/layout/widget_month_bar_2.xml | 26 +++ .../main/res/layout/widget_month_bar_3.xml | 26 +++ .../main/res/layout/widget_month_bar_4.xml | 26 +++ .../main/res/layout/widget_month_bar_5.xml | 26 +++ .../main/res/layout/widget_month_bar_6.xml | 26 +++ .../main/res/layout/widget_month_bar_7.xml | 26 +++ .../main/res/layout/widget_month_gap_1.xml | 5 + .../main/res/layout/widget_month_gap_2.xml | 5 + .../main/res/layout/widget_month_gap_3.xml | 5 + .../main/res/layout/widget_month_gap_4.xml | 5 + .../main/res/layout/widget_month_gap_5.xml | 5 + .../main/res/layout/widget_month_gap_6.xml | 5 + .../main/res/layout/widget_month_gap_7.xml | 5 + app/src/main/res/layout/widget_month_grid.xml | 6 + app/src/main/res/layout/widget_month_lane.xml | 8 + app/src/main/res/layout/widget_month_more.xml | 11 + app/src/main/res/layout/widget_month_week.xml | 220 ++++++++++++++++++ .../calendula/widget/month/MonthGridTest.kt | 39 ++-- 25 files changed, 724 insertions(+), 311 deletions(-) rename app/src/androidTest/java/de/jeanlucmakiola/calendula/widget/month/{MonthWidgetViewBudgetTest.kt => MonthWidgetPayloadTest.kt} (82%) create mode 100644 app/src/main/java/de/jeanlucmakiola/calendula/widget/month/MonthGridViews.kt create mode 100644 app/src/main/res/drawable/widget_month_bar_bg.xml create mode 100644 app/src/main/res/drawable/widget_month_today_bg.xml create mode 100644 app/src/main/res/layout/widget_month_bar_1.xml create mode 100644 app/src/main/res/layout/widget_month_bar_2.xml create mode 100644 app/src/main/res/layout/widget_month_bar_3.xml create mode 100644 app/src/main/res/layout/widget_month_bar_4.xml create mode 100644 app/src/main/res/layout/widget_month_bar_5.xml create mode 100644 app/src/main/res/layout/widget_month_bar_6.xml create mode 100644 app/src/main/res/layout/widget_month_bar_7.xml create mode 100644 app/src/main/res/layout/widget_month_gap_1.xml create mode 100644 app/src/main/res/layout/widget_month_gap_2.xml create mode 100644 app/src/main/res/layout/widget_month_gap_3.xml create mode 100644 app/src/main/res/layout/widget_month_gap_4.xml create mode 100644 app/src/main/res/layout/widget_month_gap_5.xml create mode 100644 app/src/main/res/layout/widget_month_gap_6.xml create mode 100644 app/src/main/res/layout/widget_month_gap_7.xml create mode 100644 app/src/main/res/layout/widget_month_grid.xml create mode 100644 app/src/main/res/layout/widget_month_lane.xml create mode 100644 app/src/main/res/layout/widget_month_more.xml create mode 100644 app/src/main/res/layout/widget_month_week.xml diff --git a/app/src/androidTest/java/de/jeanlucmakiola/calendula/widget/month/MonthWidgetViewBudgetTest.kt b/app/src/androidTest/java/de/jeanlucmakiola/calendula/widget/month/MonthWidgetPayloadTest.kt similarity index 82% rename from app/src/androidTest/java/de/jeanlucmakiola/calendula/widget/month/MonthWidgetViewBudgetTest.kt rename to app/src/androidTest/java/de/jeanlucmakiola/calendula/widget/month/MonthWidgetPayloadTest.kt index f57f1d2..301155a 100644 --- a/app/src/androidTest/java/de/jeanlucmakiola/calendula/widget/month/MonthWidgetViewBudgetTest.kt +++ b/app/src/androidTest/java/de/jeanlucmakiola/calendula/widget/month/MonthWidgetPayloadTest.kt @@ -4,6 +4,7 @@ import android.Manifest import android.content.ContentUris import android.content.ContentValues import android.net.Uri +import android.os.Parcel import android.provider.CalendarContract import android.util.Log import android.view.View @@ -29,12 +30,12 @@ import java.time.ZoneId /** * Renders the month widget for a packed month through Glance's own translator - * and counts what it inflates (#372): the view-id model in [WeekLayout] has to - * match what Glance really builds, or a busy month fails with "too many views". + * (#372) and checks what one size costs on the wire: the host's binder buffer is + * 1 MB for every widget update at once, and Exact sends one copy per size (#214). */ @OptIn(ExperimentalGlanceApi::class) @RunWith(AndroidJUnit4::class) -class MonthWidgetViewBudgetTest { +class MonthWidgetPayloadTest { @get:Rule val permissionRule: GrantPermissionRule = @@ -95,31 +96,39 @@ class MonthWidgetViewBudgetTest { } @Test - fun aPackedMonthRendersWithinGlancesViewIds() = runBlocking { + fun aPackedMonthRendersWithinItsPayload() = runBlocking { for (size in listOf(DpSize(250.dp, 250.dp), DpSize(320.dp, 360.dp), DpSize(420.dp, 520.dp))) { val views = MonthWidget().compose(context, size = size) - var total = 0 + val bytes = Parcel.obtain().let { parcel -> + try { + views.writeToParcel(parcel, 0) + parcel.dataSize() + } finally { + parcel.recycle() + } + } var texts = 0 instrumentation.runOnMainSync { val root = views.apply(context, FrameLayout(context)) fun walk(view: View) { - total += 1 if (view is TextView) texts += 1 if (view is ViewGroup) for (i in 0 until view.childCount) walk(view.getChildAt(i)) } walk(root) } - Log.i(TAG, "size=$size views=$total texts=$texts budget=$VIEW_BUDGET") + Log.i(TAG, "size=$size bytes=$bytes texts=$texts") // The grid itself rendered, not Glance's error layout: every day number is there. - assertThat(texts).isAtLeast(35) - assertThat(total).isAtMost(GLANCE_VIEW_IDS) + assertThat(texts).isAtLeast(42) + assertThat(bytes).isAtMost(MAX_BYTES_PER_SIZE) } } private companion object { - const val TAG = "MonthWidgetBudget" - const val ACCOUNT = "calendula-widget-budget-test" + const val TAG = "MonthWidgetPayload" + const val ACCOUNT = "calendula-widget-payload-test" const val EVENTS_PER_DAY = 6 - const val GLANCE_VIEW_IDS = 499 + + /** A quarter of the host's buffer, leaving room for a second size and every other widget. */ + const val MAX_BYTES_PER_SIZE = 256 * 1024 } } diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/widget/month/MonthGrid.kt b/app/src/main/java/de/jeanlucmakiola/calendula/widget/month/MonthGrid.kt index c4d7828..d567e4f 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/widget/month/MonthGrid.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/widget/month/MonthGrid.kt @@ -1,46 +1,11 @@ package de.jeanlucmakiola.calendula.widget.month -import android.os.Build +import androidx.compose.ui.unit.Dp import de.jeanlucmakiola.calendula.domain.EventInstance import de.jeanlucmakiola.calendula.ui.month.DaySeating import de.jeanlucmakiola.calendula.ui.month.MonthWeek import de.jeanlucmakiola.calendula.ui.month.seating import de.jeanlucmakiola.calendula.ui.month.spanAt -import kotlinx.datetime.LocalDate - -/** Event rows (lanes) a week draws at most before the rest collapse into "+N". */ -internal const val MAX_LANES = 3 - -/** - * View ids one rendering may spend. Glance has 499 and fails the whole widget - * past them (#372); staying well under also keeps a busy month's payload, sent - * once per host size, clear of the binder limit #214 ran into. - */ -internal const val VIEW_BUDGET = 400 - -/** - * What views cost in Glance 1.2's view ids. A clickable view that isn't a - * Button is wrapped in a Box sized like it, with a ripple Image and the view - * itself filling it. Below Android 12 a view with a fixed width or height is - * inflated into a sizing frame first, which takes a second id. - */ -internal class ViewIds(sdkInt: Int = Build.VERSION.SDK_INT) { - val plain = 1 - val sized = if (sdkInt < Build.VERSION_CODES.S) 2 else 1 - val clickablePlain = plain + 2 - val clickableSized = sized + 2 - - /** - * `MonthWidgetBody`'s views outside the week rows: the surface Box, its - * Column, the header row with its clickable title Box, title Text and (on a - * transparent widget) the spacer after the title pill, two - * sized clickable icon pills with their sized icons, the sized clickable - * today pill with its Text and the two sized action gaps, the spacer, the - * grid's Column and the weekday row of sized labels. - */ - val fixed = plain + plain + plain + clickablePlain + plain + plain + 2 * (clickableSized + sized) + - (clickableSized + plain) + 2 * sized + sized + plain + plain + 7 * sized -} /** A cell [cols] columns wide holding [item], or a merged gap where [item] is null. */ internal data class GridCell(val item: T?, val cols: Int) @@ -71,7 +36,7 @@ internal fun mergeGaps(columns: Int, cellAt: (Int) -> GridCell?): L return cells } -/** One week of the grid seated with [laneCap] lanes, computed once for both drawing and counting. */ +/** One week of the grid seated with [laneCap] lanes. */ internal class WeekLayout(val week: MonthWeek, val laneCap: Int) { private val seating: List = week.days.indices.map { week.seating(it, laneCap) } @@ -92,57 +57,22 @@ internal class WeekLayout(val week: MonthWeek, val laneCap: Int) { if (hidden.none { it > 0 }) emptyList() else mergeGaps(hidden.size) { col -> hidden[col].takeIf { it > 0 }?.let { GridCell(it, 1) } } } - - /** - * The view ids `WeekRow` spends on this week, counted the way it draws it: - * the week Box, the strip Row and its seven sized clickable strips, the - * content Column, the sized day-number row and its sized numbers (today's - * pill adds a sized Box around a plain Text), the sized spacer, each lane's - * Row and sized spacer with sized gaps and bars (a sized Box around a - * filling Box and Text), and the "+N" Row of sized cells. - */ - fun viewIds(today: LocalDate, ids: ViewIds): Int { - val days = week.days.size - val strips = ids.plain + days * ids.clickableSized - val numbers = ids.sized + days * ids.sized + if (today in week.days) ids.sized + ids.plain else 0 - val lanes = lanes.sumOf { cells -> - ids.plain + ids.sized + cells.sumOf { if (it.item == null) ids.sized else ids.sized + 2 * ids.plain } - } - val overflow = if (overflow.isEmpty()) 0 else ids.plain + overflow.size * ids.sized - return ids.plain + strips + ids.plain + numbers + ids.sized + lanes + overflow - } } +/** Event lanes a week row [weekHeight] tall holds under its day number. */ +internal fun lanesFitting(weekHeight: Dp): Int = + ((weekHeight.value - DAY_ROW_DP - LANES_TOP_DP) / LANE_ROW_DP).toInt().coerceAtLeast(0) + /** - * Seats each week so the whole grid fits [budget] view ids (#372). Each round - * folds the lowest lanes of whichever week saves the most ids per lane given - * up, their events joining "+N", until the month fits. + * Seats each week in [lanes] lanes. A week whose events need more gives its + * last lane to the "+N" row instead. */ -internal fun layoutWeeks( - weeks: List, - today: LocalDate, - ids: ViewIds = ViewIds(), - budget: Int = VIEW_BUDGET - ids.fixed, -): List { - val layouts = weeks.map { week -> WeekLayout(week, WeekLayout(week, MAX_LANES).usedLanes) }.toMutableList() - val costs = layouts.map { it.viewIds(today, ids) }.toMutableList() - while (costs.sum() > budget) { - var best: Triple? = null - var bestRate = 0.0 - layouts.forEachIndexed { i, layout -> - for (cap in layout.laneCap - 1 downTo 0) { - val folded = WeekLayout(layout.week, cap) - val cost = folded.viewIds(today, ids) - val rate = (costs[i] - cost).toDouble() / (layout.laneCap - cap) - if (rate > bestRate) { - bestRate = rate - best = Triple(i, folded, cost) - } - } - } - val (i, folded, cost) = best ?: break - layouts[i] = folded - costs[i] = cost - } - return layouts +internal fun fitWeeks(weeks: List, lanes: Int): List = weeks.map { week -> + val needed = WeekLayout(week, UNCAPPED_LANES).usedLanes + WeekLayout(week, if (needed <= lanes) lanes else (lanes - 1).coerceAtLeast(0)) } + +private const val DAY_ROW_DP = 18f +private const val LANES_TOP_DP = 2f +private const val LANE_ROW_DP = 15f +private const val UNCAPPED_LANES = 64 diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/widget/month/MonthGridViews.kt b/app/src/main/java/de/jeanlucmakiola/calendula/widget/month/MonthGridViews.kt new file mode 100644 index 0000000..88db8c4 --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/calendula/widget/month/MonthGridViews.kt @@ -0,0 +1,177 @@ +package de.jeanlucmakiola.calendula.widget.month + +import android.app.PendingIntent +import android.content.Context +import android.content.res.Configuration +import android.graphics.Paint +import android.os.Build +import android.view.View +import android.widget.RemoteViews +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.toArgb +import androidx.glance.unit.ColorProvider +import de.jeanlucmakiola.calendula.MainActivity +import de.jeanlucmakiola.calendula.R +import de.jeanlucmakiola.calendula.domain.EventInstance +import de.jeanlucmakiola.calendula.domain.isDeclined +import de.jeanlucmakiola.calendula.ui.common.CalendarView +import de.jeanlucmakiola.calendula.ui.common.eventFill +import de.jeanlucmakiola.calendula.ui.common.eventInk +import de.jeanlucmakiola.floret.glance.WidgetThemeMode +import kotlinx.datetime.LocalDate +import kotlinx.datetime.Month + +/** A colour for the light and the dark widget theme; equal when the widget pins one. */ +internal data class DayNight(val day: Int, val night: Int) { + fun current(context: Context): Int = + if (context.resources.configuration.uiMode and Configuration.UI_MODE_NIGHT_MASK == Configuration.UI_MODE_NIGHT_YES) night else day +} + +/** The theme colours the plain-RemoteViews grid draws with, resolved out of Glance's providers. */ +internal data class GridColors( + val onSurface: DayNight, + val onSurfaceVariant: DayNight, + val primary: DayNight, + val onPrimary: DayNight, +) + +internal fun ColorProvider.dayNight(context: Context, mode: WidgetThemeMode): DayNight { + fun at(night: Boolean): Int { + val config = Configuration(context.resources.configuration).apply { + uiMode = (uiMode and Configuration.UI_MODE_NIGHT_MASK.inv()) or + if (night) Configuration.UI_MODE_NIGHT_YES else Configuration.UI_MODE_NIGHT_NO + } + return getColor(context.createConfigurationContext(config)).toArgb() + } + return when (mode) { + WidgetThemeMode.SYSTEM -> DayNight(day = at(false), night = at(true)) + WidgetThemeMode.LIGHT -> at(false).let { DayNight(it, it) } + WidgetThemeMode.DARK -> at(true).let { DayNight(it, it) } + } +} + +private fun pinned(day: Color, night: Color, mode: WidgetThemeMode): DayNight = when (mode) { + WidgetThemeMode.SYSTEM -> DayNight(day.toArgb(), night.toArgb()) + WidgetThemeMode.LIGHT -> DayNight(day.toArgb(), day.toArgb()) + WidgetThemeMode.DARK -> DayNight(night.toArgb(), night.toArgb()) +} + +/** + * The month's week rows as one plain RemoteViews tree, embedded in the Glance + * widget so its views don't draw on Glance's view-id pool (#372): every event + * gets its bar, and only what can't fit the widget's height folds into "+N". + * + * A host may reapply a new rendering onto the views of the last one rather than + * inflate it afresh, so every container is cleared before it's filled and every + * view state is set both ways, never just when it differs from the layout. + */ +internal fun monthGridViews( + context: Context, + layouts: List, + currentMonth: Month, + today: LocalDate, + soften: Boolean, + colors: GridColors, + mode: WidgetThemeMode, +): RemoteViews { + val grid = RemoteViews(context.packageName, R.layout.widget_month_grid) + grid.removeAllViews(R.id.month_grid) + layouts.forEach { grid.addView(R.id.month_grid, weekViews(context, it, currentMonth, today, soften, colors, mode)) } + return grid +} + +private fun weekViews( + context: Context, + layout: WeekLayout, + currentMonth: Month, + today: LocalDate, + soften: Boolean, + colors: GridColors, + mode: WidgetThemeMode, +): RemoteViews { + val week = RemoteViews(context.packageName, R.layout.widget_month_week) + week.removeAllViews(R.id.week_lanes) + layout.week.days.forEachIndexed { col, date -> + week.setOnClickPendingIntent(STRIP_IDS[col], openDayIntent(context, date)) + val dayId = DAY_IDS[col] + week.setTextViewText(dayId, date.day.toString()) + val isToday = date == today + week.setColor(context, dayId, "setTextColor", when { + isToday -> colors.onPrimary + date.month == currentMonth -> colors.onSurface + else -> colors.onSurfaceVariant + }) + week.setViewVisibility(TODAY_IDS[col], if (isToday) View.VISIBLE else View.GONE) + if (isToday) week.setColor(context, TODAY_IDS[col], "setColorFilter", colors.primary) + } + layout.lanes.forEach { cells -> + val lane = laneViews(context) + cells.forEach { cell -> + val event = cell.item + lane.addView(R.id.lane, if (event == null) gap(context, cell.cols) else bar(context, event, cell.cols, soften, mode)) + } + week.addView(R.id.week_lanes, lane) + } + if (layout.overflow.isNotEmpty()) { + val lane = laneViews(context) + layout.overflow.forEach { cell -> + val count = cell.item + if (count == null) { + lane.addView(R.id.lane, gap(context, cell.cols)) + } else { + val more = RemoteViews(context.packageName, R.layout.widget_month_more) + more.setTextViewText(R.id.more, "+$count") + more.setColor(context, R.id.more, "setTextColor", colors.onSurfaceVariant) + lane.addView(R.id.lane, more) + } + } + week.addView(R.id.week_lanes, lane) + } + return week +} + +private fun laneViews(context: Context) = + RemoteViews(context.packageName, R.layout.widget_month_lane).apply { removeAllViews(R.id.lane) } + +private fun gap(context: Context, cols: Int) = RemoteViews(context.packageName, GAP_LAYOUTS[cols - 1]) + +private fun bar(context: Context, event: EventInstance, cols: Int, soften: Boolean, mode: WidgetThemeMode): RemoteViews { + val views = RemoteViews(context.packageName, BAR_LAYOUTS[cols - 1]) + val dayFill = eventFill(event.color, dark = false, soften) + val nightFill = eventFill(event.color, dark = true, soften) + views.setColor(context, R.id.bar_bg, "setColorFilter", pinned(dayFill, nightFill, mode)) + views.setTextViewText(R.id.bar_title, event.title.ifBlank { context.getString(R.string.event_untitled) }) + views.setColor(context, R.id.bar_title, "setTextColor", pinned(eventInk(dayFill), eventInk(nightFill), mode)) + val strike = if (event.isDeclined) Paint.STRIKE_THRU_TEXT_FLAG else 0 + views.setInt(R.id.bar_title, "setPaintFlags", strike or Paint.ANTI_ALIAS_FLAG or Paint.SUBPIXEL_TEXT_FLAG) + return views +} + +private fun RemoteViews.setColor(context: Context, id: Int, method: String, color: DayNight) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S && color.day != color.night) { + setColorInt(id, method, color.day, color.night) + } else { + setInt(id, method, color.current(context)) + } +} + +private fun openDayIntent(context: Context, date: LocalDate): PendingIntent = PendingIntent.getActivity( + context, + date.toEpochDays().toInt(), + MainActivity.openDateIntent(context, date, CalendarView.Month), + PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT, +) + +private val STRIP_IDS = intArrayOf(R.id.strip_0, R.id.strip_1, R.id.strip_2, R.id.strip_3, R.id.strip_4, R.id.strip_5, R.id.strip_6) +private val DAY_IDS = intArrayOf(R.id.day_0, R.id.day_1, R.id.day_2, R.id.day_3, R.id.day_4, R.id.day_5, R.id.day_6) +private val TODAY_IDS = intArrayOf( + R.id.today_bg_0, R.id.today_bg_1, R.id.today_bg_2, R.id.today_bg_3, R.id.today_bg_4, R.id.today_bg_5, R.id.today_bg_6, +) +private val BAR_LAYOUTS = intArrayOf( + R.layout.widget_month_bar_1, R.layout.widget_month_bar_2, R.layout.widget_month_bar_3, R.layout.widget_month_bar_4, + R.layout.widget_month_bar_5, R.layout.widget_month_bar_6, R.layout.widget_month_bar_7, +) +private val GAP_LAYOUTS = intArrayOf( + R.layout.widget_month_gap_1, R.layout.widget_month_gap_2, R.layout.widget_month_gap_3, R.layout.widget_month_gap_4, + R.layout.widget_month_gap_5, R.layout.widget_month_gap_6, R.layout.widget_month_gap_7, +) 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 b89270e..b756d09 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 @@ -23,6 +23,7 @@ import androidx.glance.action.ActionParameters import androidx.glance.action.actionParametersOf import androidx.glance.action.clickable import androidx.glance.appwidget.GlanceAppWidget +import androidx.glance.appwidget.AndroidRemoteViews import androidx.glance.appwidget.SizeMode import androidx.glance.appwidget.action.ActionCallback import androidx.glance.appwidget.action.actionRunCallback @@ -46,6 +47,7 @@ import androidx.glance.layout.padding import androidx.glance.layout.size import androidx.glance.layout.width import androidx.glance.state.PreferencesGlanceStateDefinition +import de.jeanlucmakiola.floret.glance.LocalWidgetThemeMode import de.jeanlucmakiola.floret.glance.WidgetActionStyle import de.jeanlucmakiola.floret.glance.WidgetHeader import de.jeanlucmakiola.floret.glance.WidgetHeaderAction @@ -93,10 +95,11 @@ import java.util.Locale /** Per-widget state: the displayed month as `year * 12 + monthOrdinal`. */ private val MONTH_INDEX_KEY = intPreferencesKey("month_index") -private val LANE_HEIGHT = 14.dp -private val DAY_NUMBER_HEIGHT = 18.dp private val GRID_HPADDING = 8.dp +/** Height around the week rows: body padding, header, its spacer and the weekday row. */ +private val GRID_CHROME_HEIGHT = 62.dp + private fun currentMonthIndex(zone: TimeZone): Int = monthIndexOf(today(zone)) private fun yearMonthOf(index: Int): YearMonth = @@ -107,9 +110,9 @@ private fun yearMonthOf(index: Int): YearMonth = * event bars and titled single-day pills (the in-app lane layout via * [layoutMonthWeeks]), and prev/next/today navigation. * - * Columns are sized explicitly from [LocalSize] (hence [SizeMode.Exact]) so a - * multi-day span renders as a single Box spanning its columns — connected, no - * inter-cell seam, with rounded end caps. The displayed month lives in Glance + * The week rows are one plain RemoteViews tree ([monthGridViews]) embedded in + * the Glance widget, so a busy month isn't held to Glance's view-id pool (#372); + * the lanes each week shows come from the widget's height. The displayed month lives in Glance * state and is read reactively in the composition ([currentState]) so the arrows * move it via plain recomposition, not a (here-unreliable) widget session reload. * @@ -120,7 +123,7 @@ private fun yearMonthOf(index: Int): YearMonth = * the launcher rebinds. [SizeMode.Exact] serialises the grid once per size the * host reports, so the ceiling arrives at half the view count you would expect: a * grid that emitted a view per cell blew past it after three taps (#214). Every - * row here emits only what it fills. + * row here emits only what it fills, and lanes stop at what the height shows. */ class MonthWidget : GlanceAppWidget() { @@ -130,8 +133,8 @@ class MonthWidget : GlanceAppWidget() { * Exact rather than [SizeMode.Single] or [SizeMode.Responsive], both of which * return early from `GlanceAppWidget.resize` — under those the grid is never * recomposed for a new size and a resized widget clips instead of reflowing. - * Exact costs one serialised copy per host size, which is why a rendering is - * held to [VIEW_BUDGET] view ids, folding lanes into "+N" on a busy month. + * Exact costs one serialised copy per host size, so the grid draws only the + * lanes the size can show, folding the rest into "+N". */ override val sizeMode = SizeMode.Exact @@ -241,22 +244,26 @@ private fun MonthWidgetBody(source: MonthWidgetSource, index: Int) { // Column width from the live widget size, minus our H padding. val colW = (LocalSize.current.width - GRID_HPADDING * 2) / 7 val weeks = layoutMonthWeeks(ym, source.weekStart, source.instances, zone) + val weekHeight = (LocalSize.current.height - GRID_CHROME_HEIGHT) / weeks.size + val layouts = fitWeeks(weeks, lanesFitting(weekHeight)) + val context = LocalContext.current + val mode = LocalWidgetThemeMode.current + val colors = GridColors( + onSurface = GlanceTheme.colors.onSurface.dayNight(context, mode), + onSurfaceVariant = GlanceTheme.colors.onSurfaceVariant.dayNight(context, mode), + primary = GlanceTheme.colors.primary.dayNight(context, mode), + onPrimary = GlanceTheme.colors.onPrimary.dayNight(context, mode), + ) MonthHeader(label = monthLabel(ym, source.today.year), today = source.today) Spacer(GlanceModifier.height(4.dp)) // 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(), - ) - } + AndroidRemoteViews( + remoteViews = monthGridViews(context, layouts, ym.month, source.today, source.soften, colors, mode), + modifier = GlanceModifier.fillMaxWidth().defaultWeight(), + ) } } } @@ -324,176 +331,6 @@ private fun weekdayNarrowNames(weekStart: DayOfWeek): List { } } -@Composable -private fun WeekRow( - layout: WeekLayout, - currentMonth: Month, - today: LocalDate, - soften: Boolean, - colW: Dp, - modifier: GlanceModifier, -) { - val context = LocalContext.current - val week = layout.week - Box(modifier = modifier.fillMaxWidth()) { - // Day-open targets, one full-height strip per column, sitting *under* the - // content: a tap anywhere in a day still opens it, as in the app, without - // every cell in the column carrying a click target of its own. The content - // above is not clickable, event bars included, so touches fall through to - // this layer and a day opens wherever it is tapped (#386). - Row(modifier = GlanceModifier.fillMaxSize()) { - week.days.forEach { date -> - Box( - modifier = GlanceModifier - .width(colW) - .fillMaxHeight() - .clickable(openDayAction(context, date)), - ) {} - } - } - Column(modifier = GlanceModifier.fillMaxWidth()) { - // Day numbers. Fixed height so every week's number row lines up whether - // or not it holds today's larger pill. - Row( - modifier = GlanceModifier.fillMaxWidth().height(DAY_NUMBER_HEIGHT), - verticalAlignment = Alignment.CenterVertically, - ) { - week.days.forEach { date -> - DayNumber( - date = date, - isToday = date == today, - inMonth = date.month == currentMonth, - colW = colW, - ) - } - } - Spacer(GlanceModifier.height(2.dp)) - // One lane row per event row. A multi-day span is a single Box spanning - // 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. - // The grid's view budget caps the lanes per week (#372); what doesn't - // fit folds into the "+N" row. Counted the same way by WeekLayout.viewIds. - layout.lanes.forEach { cells -> - LaneRow(cells = cells, soften = soften, colW = colW) - Spacer(GlanceModifier.height(1.dp)) - } - if (layout.overflow.isNotEmpty()) OverflowRow(cells = layout.overflow, colW = colW) - } - } -} - -/** - * Open [date]'s day view rooted in the month view (so back returns to the month - * grid) — the same target the in-app month grid uses when a day cell is tapped. - * The only tap target in a day column, so a tap anywhere on a day opens it. - */ -private fun openDayAction(context: Context, date: LocalDate) = - actionStartActivity(MainActivity.openDateIntent(context, date, CalendarView.Month)) - -/** - * A day's number. Every day but today is a bare centred [Text] carrying the column - * width itself — a wrapping Box costs three views, and there are 42 of these. - * Today keeps its filled circle, which does need the Box. - */ -@Composable -private fun DayNumber(date: LocalDate, isToday: Boolean, inMonth: Boolean, colW: Dp) { - val style = TextStyle( - color = when { - isToday -> GlanceTheme.colors.onPrimary - inMonth -> GlanceTheme.colors.onSurface - else -> GlanceTheme.colors.onSurfaceVariant - }, - fontSize = 11.sp, - fontWeight = if (isToday) FontWeight.Bold else FontWeight.Normal, - textAlign = TextAlign.Center, - ) - if (!isToday) { - Text(text = date.day.toString(), style = style, modifier = GlanceModifier.width(colW)) - return - } - Box(modifier = GlanceModifier.width(colW), contentAlignment = Alignment.Center) { - Box( - modifier = GlanceModifier - .size(DAY_NUMBER_HEIGHT) - .cornerRadius(DAY_NUMBER_HEIGHT / 2) - .background(GlanceTheme.colors.primary), - contentAlignment = Alignment.Center, - ) { - Text(text = date.day.toString(), style = style) - } - } -} - -@Composable -private fun LaneRow(cells: List>, soften: Boolean, colW: Dp) { - Row(modifier = GlanceModifier.fillMaxWidth()) { - cells.forEach { cell -> - val event = cell.item - if (event == null) { - Spacer(GlanceModifier.width(colW * cell.cols).height(LANE_HEIGHT)) - } else { - SpanBar( - event = event, - soften = soften, - width = colW * cell.cols, - ) - } - } - } -} - -/** A single connected, rounded event bar [width] wide with its clipped title. */ -@Composable -private fun SpanBar(event: EventInstance, soften: Boolean, width: Dp) { - val context = LocalContext.current - val dayFill = eventFill(event.color, dark = false, soften) - val nightFill = eventFill(event.color, dark = true, soften) - Box( - modifier = GlanceModifier - .width(width) - .height(LANE_HEIGHT) - .padding(horizontal = 1.dp), - ) { - Box( - modifier = GlanceModifier - .fillMaxSize() - .cornerRadius(4.dp) - .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 = themedColor(day = eventInk(dayFill), night = eventInk(nightFill)), - fontSize = 9.sp, - textDecoration = glanceDeclinedDecoration(event.isDeclined), - ), - modifier = GlanceModifier.padding(horizontal = 3.dp), - ) - } - } -} - -/** The "+N" row, drawn only for weeks that hide something, gaps merged as in a lane. */ -@Composable -private fun OverflowRow(cells: List>, colW: Dp) { - Row(modifier = GlanceModifier.fillMaxWidth()) { - cells.forEach { cell -> - if (cell.item == null) { - Spacer(GlanceModifier.width(colW * cell.cols).height(LANE_HEIGHT)) - } else { - Text( - text = "+${cell.item}", - maxLines = 1, - style = TextStyle(color = GlanceTheme.colors.onSurfaceVariant, fontSize = 9.sp), - modifier = GlanceModifier.width(colW).padding(start = 3.dp), - ) - } - } - } -} - @Composable private fun PermissionMessage() { val context = LocalContext.current diff --git a/app/src/main/res/drawable/widget_month_bar_bg.xml b/app/src/main/res/drawable/widget_month_bar_bg.xml new file mode 100644 index 0000000..eb18a82 --- /dev/null +++ b/app/src/main/res/drawable/widget_month_bar_bg.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/app/src/main/res/drawable/widget_month_today_bg.xml b/app/src/main/res/drawable/widget_month_today_bg.xml new file mode 100644 index 0000000..3626003 --- /dev/null +++ b/app/src/main/res/drawable/widget_month_today_bg.xml @@ -0,0 +1,4 @@ + + + + diff --git a/app/src/main/res/layout/widget_month_bar_1.xml b/app/src/main/res/layout/widget_month_bar_1.xml new file mode 100644 index 0000000..38b7c5a --- /dev/null +++ b/app/src/main/res/layout/widget_month_bar_1.xml @@ -0,0 +1,26 @@ + + + + + + + diff --git a/app/src/main/res/layout/widget_month_bar_2.xml b/app/src/main/res/layout/widget_month_bar_2.xml new file mode 100644 index 0000000..5310c85 --- /dev/null +++ b/app/src/main/res/layout/widget_month_bar_2.xml @@ -0,0 +1,26 @@ + + + + + + + diff --git a/app/src/main/res/layout/widget_month_bar_3.xml b/app/src/main/res/layout/widget_month_bar_3.xml new file mode 100644 index 0000000..75eb3c3 --- /dev/null +++ b/app/src/main/res/layout/widget_month_bar_3.xml @@ -0,0 +1,26 @@ + + + + + + + diff --git a/app/src/main/res/layout/widget_month_bar_4.xml b/app/src/main/res/layout/widget_month_bar_4.xml new file mode 100644 index 0000000..bb6557e --- /dev/null +++ b/app/src/main/res/layout/widget_month_bar_4.xml @@ -0,0 +1,26 @@ + + + + + + + diff --git a/app/src/main/res/layout/widget_month_bar_5.xml b/app/src/main/res/layout/widget_month_bar_5.xml new file mode 100644 index 0000000..6cd75a8 --- /dev/null +++ b/app/src/main/res/layout/widget_month_bar_5.xml @@ -0,0 +1,26 @@ + + + + + + + diff --git a/app/src/main/res/layout/widget_month_bar_6.xml b/app/src/main/res/layout/widget_month_bar_6.xml new file mode 100644 index 0000000..e0cf5f2 --- /dev/null +++ b/app/src/main/res/layout/widget_month_bar_6.xml @@ -0,0 +1,26 @@ + + + + + + + diff --git a/app/src/main/res/layout/widget_month_bar_7.xml b/app/src/main/res/layout/widget_month_bar_7.xml new file mode 100644 index 0000000..17d7b32 --- /dev/null +++ b/app/src/main/res/layout/widget_month_bar_7.xml @@ -0,0 +1,26 @@ + + + + + + + diff --git a/app/src/main/res/layout/widget_month_gap_1.xml b/app/src/main/res/layout/widget_month_gap_1.xml new file mode 100644 index 0000000..6d18a12 --- /dev/null +++ b/app/src/main/res/layout/widget_month_gap_1.xml @@ -0,0 +1,5 @@ + + diff --git a/app/src/main/res/layout/widget_month_gap_2.xml b/app/src/main/res/layout/widget_month_gap_2.xml new file mode 100644 index 0000000..5d6f690 --- /dev/null +++ b/app/src/main/res/layout/widget_month_gap_2.xml @@ -0,0 +1,5 @@ + + diff --git a/app/src/main/res/layout/widget_month_gap_3.xml b/app/src/main/res/layout/widget_month_gap_3.xml new file mode 100644 index 0000000..d73cef0 --- /dev/null +++ b/app/src/main/res/layout/widget_month_gap_3.xml @@ -0,0 +1,5 @@ + + diff --git a/app/src/main/res/layout/widget_month_gap_4.xml b/app/src/main/res/layout/widget_month_gap_4.xml new file mode 100644 index 0000000..b0ddf81 --- /dev/null +++ b/app/src/main/res/layout/widget_month_gap_4.xml @@ -0,0 +1,5 @@ + + diff --git a/app/src/main/res/layout/widget_month_gap_5.xml b/app/src/main/res/layout/widget_month_gap_5.xml new file mode 100644 index 0000000..d36a2aa --- /dev/null +++ b/app/src/main/res/layout/widget_month_gap_5.xml @@ -0,0 +1,5 @@ + + diff --git a/app/src/main/res/layout/widget_month_gap_6.xml b/app/src/main/res/layout/widget_month_gap_6.xml new file mode 100644 index 0000000..6da1869 --- /dev/null +++ b/app/src/main/res/layout/widget_month_gap_6.xml @@ -0,0 +1,5 @@ + + diff --git a/app/src/main/res/layout/widget_month_gap_7.xml b/app/src/main/res/layout/widget_month_gap_7.xml new file mode 100644 index 0000000..6ba0099 --- /dev/null +++ b/app/src/main/res/layout/widget_month_gap_7.xml @@ -0,0 +1,5 @@ + + diff --git a/app/src/main/res/layout/widget_month_grid.xml b/app/src/main/res/layout/widget_month_grid.xml new file mode 100644 index 0000000..bdf0ebf --- /dev/null +++ b/app/src/main/res/layout/widget_month_grid.xml @@ -0,0 +1,6 @@ + + diff --git a/app/src/main/res/layout/widget_month_lane.xml b/app/src/main/res/layout/widget_month_lane.xml new file mode 100644 index 0000000..57a8df1 --- /dev/null +++ b/app/src/main/res/layout/widget_month_lane.xml @@ -0,0 +1,8 @@ + + diff --git a/app/src/main/res/layout/widget_month_more.xml b/app/src/main/res/layout/widget_month_more.xml new file mode 100644 index 0000000..63f9b17 --- /dev/null +++ b/app/src/main/res/layout/widget_month_more.xml @@ -0,0 +1,11 @@ + + diff --git a/app/src/main/res/layout/widget_month_week.xml b/app/src/main/res/layout/widget_month_week.xml new file mode 100644 index 0000000..e44c7d9 --- /dev/null +++ b/app/src/main/res/layout/widget_month_week.xml @@ -0,0 +1,220 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/test/java/de/jeanlucmakiola/calendula/widget/month/MonthGridTest.kt b/app/src/test/java/de/jeanlucmakiola/calendula/widget/month/MonthGridTest.kt index 4af9e7a..e16697a 100644 --- a/app/src/test/java/de/jeanlucmakiola/calendula/widget/month/MonthGridTest.kt +++ b/app/src/test/java/de/jeanlucmakiola/calendula/widget/month/MonthGridTest.kt @@ -13,21 +13,18 @@ import kotlinx.datetime.Month import kotlinx.datetime.TimeZone import kotlinx.datetime.YearMonth import kotlinx.datetime.plus +import androidx.compose.ui.unit.dp import org.junit.jupiter.api.Test -/** The month widget's view budget (#372). */ +/** How the month widget seats a week's events into lanes (#372). */ class MonthGridTest { private val zone = TimeZone.UTC private val jul26 = YearMonth(2026, Month.JULY) - private val today = LocalDate(2026, 7, 15) /** Monday Jul 6 to Sunday Jul 12. */ private val monday = LocalDate(2026, 7, 6) - private val android16 = ViewIds(sdkInt = 36) - private val android10 = ViewIds(sdkInt = 29) - private fun weeks(events: List): List = layoutMonthWeeks(jul26, DayOfWeek.MONDAY, events, zone) @@ -43,33 +40,25 @@ class MonthGridTest { ) } - private fun List.cost(ids: ViewIds) = sumOf { it.viewIds(today, ids) } - @Test - fun `a quiet month keeps every lane it uses`() { - val layouts = layoutWeeks(busyWeeks(perDay = 1), today, android16) - assertThat(layouts.map { it.laneCap }.distinct()).containsExactly(1) + fun `a week that fits keeps every lane`() { + val layouts = fitWeeks(busyWeeks(perDay = 2), lanes = 3) + assertThat(layouts.map { it.laneCap }.distinct()).containsExactly(3) assertThat(layouts.all { it.overflow.isEmpty() }).isTrue() } @Test - fun `a full month folds lanes until it fits, on every Android version`() { - for (ids in listOf(android16, android10)) { - val weeks = busyWeeks(perDay = 5) - assertThat(weeks.map { WeekLayout(it, MAX_LANES) }.cost(ids)).isGreaterThan(VIEW_BUDGET - ids.fixed) - - val layouts = layoutWeeks(weeks, today, ids) - assertThat(layouts.cost(ids)).isAtMost(VIEW_BUDGET - ids.fixed) - assertThat(layouts.any { it.laneCap < MAX_LANES }).isTrue() - } + fun `a week that doesn't fit gives its last lane to the overflow`() { + val layouts = fitWeeks(busyWeeks(perDay = 5), lanes = 3) + assertThat(layouts.map { it.laneCap }.distinct()).containsExactly(2) + assertThat(layouts.all { it.overflow.isNotEmpty() }).isTrue() } @Test - fun `older Android folds more, since a sized view costs it two ids`() { - val weeks = busyWeeks(perDay = 3) - val newer = layoutWeeks(weeks, today, android16).sumOf { it.laneCap } - val older = layoutWeeks(weeks, today, android10).sumOf { it.laneCap } - assertThat(older).isLessThan(newer) + fun `a taller week fits more lanes`() { + assertThat(lanesFitting(19.dp)).isEqualTo(0) + assertThat(lanesFitting(50.dp)).isEqualTo(2) + assertThat(lanesFitting(100.dp)).isEqualTo(5) } @Test @@ -83,7 +72,7 @@ class MonthGridTest { @Test fun `a multi-day bar is one cell as wide as its days`() { val bar = allDayEvent(monday.plus(1, DateTimeUnit.DAY), monday.plus(3, DateTimeUnit.DAY), id = 1) - val layout = WeekLayout(weeks(listOf(bar))[1], MAX_LANES) + val layout = WeekLayout(weeks(listOf(bar))[1], laneCap = 3) assertThat(layout.lanes).hasSize(1) assertThat(layout.lanes[0]).containsExactly(GridCell(null, 1), GridCell(bar, 3), GridCell(null, 3)).inOrder() } From 94b04c13652ef5d2347c5d730a6d3c83440c42c4 Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Fri, 23 Oct 2026 15:45:54 +0200 Subject: [PATCH 12/24] Month widget grid: palette-following colours, day ripples, bold today, strike via span, lane cap (#372) --- .../widget/month/MonthWidgetPayloadTest.kt | 18 ++-- .../calendula/widget/month/MonthGrid.kt | 20 +++-- .../calendula/widget/month/MonthGridViews.kt | 83 +++++++------------ .../calendula/widget/month/MonthWidget.kt | 37 ++++----- app/src/main/res/layout/widget_month_week.xml | 21 +++-- .../calendula/widget/month/MonthGridTest.kt | 12 +++ floret-kit | 2 +- 7 files changed, 98 insertions(+), 95 deletions(-) diff --git a/app/src/androidTest/java/de/jeanlucmakiola/calendula/widget/month/MonthWidgetPayloadTest.kt b/app/src/androidTest/java/de/jeanlucmakiola/calendula/widget/month/MonthWidgetPayloadTest.kt index 301155a..5b02619 100644 --- a/app/src/androidTest/java/de/jeanlucmakiola/calendula/widget/month/MonthWidgetPayloadTest.kt +++ b/app/src/androidTest/java/de/jeanlucmakiola/calendula/widget/month/MonthWidgetPayloadTest.kt @@ -25,6 +25,7 @@ import org.junit.Before import org.junit.Rule import org.junit.Test import org.junit.runner.RunWith +import de.jeanlucmakiola.calendula.R import java.time.LocalDate import java.time.ZoneId @@ -97,7 +98,7 @@ class MonthWidgetPayloadTest { @Test fun aPackedMonthRendersWithinItsPayload() = runBlocking { - for (size in listOf(DpSize(250.dp, 250.dp), DpSize(320.dp, 360.dp), DpSize(420.dp, 520.dp))) { + for (size in listOf(DpSize(250.dp, 250.dp), DpSize(320.dp, 360.dp), DpSize(420.dp, 520.dp), DpSize(600.dp, 1000.dp))) { val views = MonthWidget().compose(context, size = size) val bytes = Parcel.obtain().let { parcel -> try { @@ -107,18 +108,21 @@ class MonthWidgetPayloadTest { parcel.recycle() } } - var texts = 0 + var dayNumbers = 0 + var bars = 0 instrumentation.runOnMainSync { val root = views.apply(context, FrameLayout(context)) fun walk(view: View) { - if (view is TextView) texts += 1 + if (view.id in DAY_IDS && view is TextView && view.text.isNotEmpty()) dayNumbers += 1 + if (view.id == R.id.bar_title) bars += 1 if (view is ViewGroup) for (i in 0 until view.childCount) walk(view.getChildAt(i)) } walk(root) } - Log.i(TAG, "size=$size bytes=$bytes texts=$texts") - // The grid itself rendered, not Glance's error layout: every day number is there. - assertThat(texts).isAtLeast(42) + Log.i(TAG, "size=$size bytes=$bytes days=$dayNumbers bars=$bars") + // The grid itself rendered, not Glance's error layout: a number in every day of every week. + assertThat(dayNumbers % 7).isEqualTo(0) + assertThat(dayNumbers).isAtLeast(28) assertThat(bytes).isAtMost(MAX_BYTES_PER_SIZE) } } @@ -128,6 +132,8 @@ class MonthWidgetPayloadTest { const val ACCOUNT = "calendula-widget-payload-test" const val EVENTS_PER_DAY = 6 + val DAY_IDS = setOf(R.id.day_0, R.id.day_1, R.id.day_2, R.id.day_3, R.id.day_4, R.id.day_5, R.id.day_6) + /** A quarter of the host's buffer, leaving room for a second size and every other widget. */ const val MAX_BYTES_PER_SIZE = 256 * 1024 } diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/widget/month/MonthGrid.kt b/app/src/main/java/de/jeanlucmakiola/calendula/widget/month/MonthGrid.kt index d567e4f..e43d95c 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/widget/month/MonthGrid.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/widget/month/MonthGrid.kt @@ -36,8 +36,11 @@ internal fun mergeGaps(columns: Int, cellAt: (Int) -> GridCell?): L return cells } -/** One week of the grid seated with [laneCap] lanes. */ -internal class WeekLayout(val week: MonthWeek, val laneCap: Int) { +/** + * One week of the grid seated with [laneCap] lanes. [showsOverflow] is off when + * the week has no room for even the "+N" row. + */ +internal class WeekLayout(val week: MonthWeek, val laneCap: Int, val showsOverflow: Boolean = true) { private val seating: List = week.days.indices.map { week.seating(it, laneCap) } /** Lanes filled from the top; empty lanes below are not drawn. */ @@ -59,19 +62,26 @@ internal class WeekLayout(val week: MonthWeek, val laneCap: Int) { } } -/** Event lanes a week row [weekHeight] tall holds under its day number. */ +/** + * Event lanes a week row [weekHeight] tall holds under its day number, held to + * [MAX_LANES] so a tall widget's payload stays bounded (#214). + */ internal fun lanesFitting(weekHeight: Dp): Int = - ((weekHeight.value - DAY_ROW_DP - LANES_TOP_DP) / LANE_ROW_DP).toInt().coerceAtLeast(0) + ((weekHeight.value - DAY_ROW_DP - LANES_TOP_DP) / LANE_ROW_DP).toInt().coerceIn(0, MAX_LANES) /** * Seats each week in [lanes] lanes. A week whose events need more gives its * last lane to the "+N" row instead. */ internal fun fitWeeks(weeks: List, lanes: Int): List = weeks.map { week -> + if (lanes == 0) return@map WeekLayout(week, laneCap = 0, showsOverflow = false) val needed = WeekLayout(week, UNCAPPED_LANES).usedLanes - WeekLayout(week, if (needed <= lanes) lanes else (lanes - 1).coerceAtLeast(0)) + WeekLayout(week, if (needed <= lanes) lanes else lanes - 1) } +/** Lanes a week shows at most, however tall the widget. */ +internal const val MAX_LANES = 8 + private const val DAY_ROW_DP = 18f private const val LANES_TOP_DP = 2f private const val LANE_ROW_DP = 15f diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/widget/month/MonthGridViews.kt b/app/src/main/java/de/jeanlucmakiola/calendula/widget/month/MonthGridViews.kt index 88db8c4..29215f7 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/widget/month/MonthGridViews.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/widget/month/MonthGridViews.kt @@ -2,14 +2,13 @@ package de.jeanlucmakiola.calendula.widget.month import android.app.PendingIntent import android.content.Context -import android.content.res.Configuration -import android.graphics.Paint -import android.os.Build +import android.graphics.Typeface +import android.text.SpannableString +import android.text.Spanned +import android.text.style.StrikethroughSpan +import android.text.style.StyleSpan import android.view.View import android.widget.RemoteViews -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.toArgb -import androidx.glance.unit.ColorProvider import de.jeanlucmakiola.calendula.MainActivity import de.jeanlucmakiola.calendula.R import de.jeanlucmakiola.calendula.domain.EventInstance @@ -17,45 +16,22 @@ import de.jeanlucmakiola.calendula.domain.isDeclined import de.jeanlucmakiola.calendula.ui.common.CalendarView import de.jeanlucmakiola.calendula.ui.common.eventFill import de.jeanlucmakiola.calendula.ui.common.eventInk +import de.jeanlucmakiola.floret.glance.RemoteColor import de.jeanlucmakiola.floret.glance.WidgetThemeMode +import de.jeanlucmakiola.floret.glance.remoteColor +import de.jeanlucmakiola.floret.glance.setImageTint +import de.jeanlucmakiola.floret.glance.setTextColor import kotlinx.datetime.LocalDate import kotlinx.datetime.Month -/** A colour for the light and the dark widget theme; equal when the widget pins one. */ -internal data class DayNight(val day: Int, val night: Int) { - fun current(context: Context): Int = - if (context.resources.configuration.uiMode and Configuration.UI_MODE_NIGHT_MASK == Configuration.UI_MODE_NIGHT_YES) night else day -} - -/** The theme colours the plain-RemoteViews grid draws with, resolved out of Glance's providers. */ +/** The theme colours the plain-RemoteViews grid draws with. */ internal data class GridColors( - val onSurface: DayNight, - val onSurfaceVariant: DayNight, - val primary: DayNight, - val onPrimary: DayNight, + val onSurface: RemoteColor, + val onSurfaceVariant: RemoteColor, + val primary: RemoteColor, + val onPrimary: RemoteColor, ) -internal fun ColorProvider.dayNight(context: Context, mode: WidgetThemeMode): DayNight { - fun at(night: Boolean): Int { - val config = Configuration(context.resources.configuration).apply { - uiMode = (uiMode and Configuration.UI_MODE_NIGHT_MASK.inv()) or - if (night) Configuration.UI_MODE_NIGHT_YES else Configuration.UI_MODE_NIGHT_NO - } - return getColor(context.createConfigurationContext(config)).toArgb() - } - return when (mode) { - WidgetThemeMode.SYSTEM -> DayNight(day = at(false), night = at(true)) - WidgetThemeMode.LIGHT -> at(false).let { DayNight(it, it) } - WidgetThemeMode.DARK -> at(true).let { DayNight(it, it) } - } -} - -private fun pinned(day: Color, night: Color, mode: WidgetThemeMode): DayNight = when (mode) { - WidgetThemeMode.SYSTEM -> DayNight(day.toArgb(), night.toArgb()) - WidgetThemeMode.LIGHT -> DayNight(day.toArgb(), day.toArgb()) - WidgetThemeMode.DARK -> DayNight(night.toArgb(), night.toArgb()) -} - /** * The month's week rows as one plain RemoteViews tree, embedded in the Glance * widget so its views don't draw on Glance's view-id pool (#372): every event @@ -94,15 +70,15 @@ private fun weekViews( layout.week.days.forEachIndexed { col, date -> week.setOnClickPendingIntent(STRIP_IDS[col], openDayIntent(context, date)) val dayId = DAY_IDS[col] - week.setTextViewText(dayId, date.day.toString()) val isToday = date == today - week.setColor(context, dayId, "setTextColor", when { + week.setTextViewText(dayId, if (isToday) bold(date.day.toString()) else date.day.toString()) + week.setTextColor(context, dayId, when { isToday -> colors.onPrimary date.month == currentMonth -> colors.onSurface else -> colors.onSurfaceVariant }) week.setViewVisibility(TODAY_IDS[col], if (isToday) View.VISIBLE else View.GONE) - if (isToday) week.setColor(context, TODAY_IDS[col], "setColorFilter", colors.primary) + if (isToday) week.setImageTint(context, TODAY_IDS[col], colors.primary) } layout.lanes.forEach { cells -> val lane = laneViews(context) @@ -112,7 +88,7 @@ private fun weekViews( } week.addView(R.id.week_lanes, lane) } - if (layout.overflow.isNotEmpty()) { + if (layout.showsOverflow && layout.overflow.isNotEmpty()) { val lane = laneViews(context) layout.overflow.forEach { cell -> val count = cell.item @@ -121,7 +97,7 @@ private fun weekViews( } else { val more = RemoteViews(context.packageName, R.layout.widget_month_more) more.setTextViewText(R.id.more, "+$count") - more.setColor(context, R.id.more, "setTextColor", colors.onSurfaceVariant) + more.setTextColor(context, R.id.more, colors.onSurfaceVariant) lane.addView(R.id.lane, more) } } @@ -139,21 +115,18 @@ private fun bar(context: Context, event: EventInstance, cols: Int, soften: Boole val views = RemoteViews(context.packageName, BAR_LAYOUTS[cols - 1]) val dayFill = eventFill(event.color, dark = false, soften) val nightFill = eventFill(event.color, dark = true, soften) - views.setColor(context, R.id.bar_bg, "setColorFilter", pinned(dayFill, nightFill, mode)) - views.setTextViewText(R.id.bar_title, event.title.ifBlank { context.getString(R.string.event_untitled) }) - views.setColor(context, R.id.bar_title, "setTextColor", pinned(eventInk(dayFill), eventInk(nightFill), mode)) - val strike = if (event.isDeclined) Paint.STRIKE_THRU_TEXT_FLAG else 0 - views.setInt(R.id.bar_title, "setPaintFlags", strike or Paint.ANTI_ALIAS_FLAG or Paint.SUBPIXEL_TEXT_FLAG) + views.setImageTint(context, R.id.bar_bg, mode.remoteColor(dayFill, nightFill)) + val title = event.title.ifBlank { context.getString(R.string.event_untitled) } + // A span, like Glance's own decoration: paint flags would drop the text's defaults. + views.setTextViewText(R.id.bar_title, if (event.isDeclined) span(title, StrikethroughSpan()) else title) + views.setTextColor(context, R.id.bar_title, mode.remoteColor(eventInk(dayFill), eventInk(nightFill))) return views } -private fun RemoteViews.setColor(context: Context, id: Int, method: String, color: DayNight) { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S && color.day != color.night) { - setColorInt(id, method, color.day, color.night) - } else { - setInt(id, method, color.current(context)) - } -} +private fun bold(text: String): CharSequence = span(text, StyleSpan(Typeface.BOLD)) + +private fun span(text: String, span: Any): CharSequence = + SpannableString(text).apply { setSpan(span, 0, length, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE) } private fun openDayIntent(context: Context, date: LocalDate): PendingIntent = PendingIntent.getActivity( 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 b756d09..8ce7fe8 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 @@ -1,7 +1,5 @@ package de.jeanlucmakiola.calendula.widget.month -import de.jeanlucmakiola.calendula.domain.isDeclined -import de.jeanlucmakiola.calendula.widget.glanceDeclinedDecoration import android.content.Context import androidx.compose.runtime.Composable import androidx.compose.runtime.SideEffect @@ -21,25 +19,21 @@ import androidx.glance.LocalContext import androidx.glance.LocalSize import androidx.glance.action.ActionParameters import androidx.glance.action.actionParametersOf -import androidx.glance.action.clickable import androidx.glance.appwidget.GlanceAppWidget import androidx.glance.appwidget.AndroidRemoteViews import androidx.glance.appwidget.SizeMode import androidx.glance.appwidget.action.ActionCallback import androidx.glance.appwidget.action.actionRunCallback import androidx.glance.appwidget.action.actionStartActivity -import androidx.glance.appwidget.cornerRadius import androidx.glance.appwidget.provideContent import androidx.glance.appwidget.state.getAppWidgetState import androidx.glance.appwidget.state.updateAppWidgetState -import androidx.glance.background import androidx.glance.currentState import androidx.glance.layout.Alignment import androidx.glance.layout.Box import androidx.glance.layout.Column import androidx.glance.layout.Row import androidx.glance.layout.Spacer -import androidx.glance.layout.fillMaxHeight import androidx.glance.layout.fillMaxSize import androidx.glance.layout.fillMaxWidth import androidx.glance.layout.height @@ -48,29 +42,24 @@ import androidx.glance.layout.size import androidx.glance.layout.width import androidx.glance.state.PreferencesGlanceStateDefinition import de.jeanlucmakiola.floret.glance.LocalWidgetThemeMode +import de.jeanlucmakiola.floret.glance.WidgetColorRole +import de.jeanlucmakiola.floret.glance.remoteColor import de.jeanlucmakiola.floret.glance.WidgetActionStyle import de.jeanlucmakiola.floret.glance.WidgetHeader import de.jeanlucmakiola.floret.glance.WidgetHeaderAction import de.jeanlucmakiola.floret.glance.WidgetHeaderActionGap import de.jeanlucmakiola.floret.glance.WidgetHeaderTextAction -import androidx.glance.text.FontWeight import androidx.glance.text.Text import androidx.glance.text.TextAlign import androidx.glance.text.TextStyle -import androidx.glance.unit.ColorProvider import de.jeanlucmakiola.calendula.MainActivity import de.jeanlucmakiola.calendula.R -import de.jeanlucmakiola.calendula.domain.EventInstance import de.jeanlucmakiola.calendula.ui.common.CalendarView import de.jeanlucmakiola.calendula.ui.common.formatCalendarTitle -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.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 @@ -97,8 +86,13 @@ private val MONTH_INDEX_KEY = intPreferencesKey("month_index") private val GRID_HPADDING = 8.dp -/** Height around the week rows: body padding, header, its spacer and the weekday row. */ -private val GRID_CHROME_HEIGHT = 62.dp +/** + * Height around the week rows: body padding, the header's bottom padding and + * spacer, then the header (its 28dp pills, or a title line in its transparent + * pill when that's taller) and the weekday row, the text lines at [fontScale]. + */ +private fun gridChromeHeight(fontScale: Float): Dp = + 18.dp + maxOf(28.dp, 19.dp * fontScale + 8.dp) + 15.dp * fontScale private fun currentMonthIndex(zone: TimeZone): Int = monthIndexOf(today(zone)) @@ -244,15 +238,16 @@ private fun MonthWidgetBody(source: MonthWidgetSource, index: Int) { // Column width from the live widget size, minus our H padding. val colW = (LocalSize.current.width - GRID_HPADDING * 2) / 7 val weeks = layoutMonthWeeks(ym, source.weekStart, source.instances, zone) - val weekHeight = (LocalSize.current.height - GRID_CHROME_HEIGHT) / weeks.size - val layouts = fitWeeks(weeks, lanesFitting(weekHeight)) val context = LocalContext.current + val fontScale = context.resources.configuration.fontScale + val weekHeight = (LocalSize.current.height - gridChromeHeight(fontScale)) / weeks.size + val layouts = fitWeeks(weeks, lanesFitting(weekHeight)) val mode = LocalWidgetThemeMode.current val colors = GridColors( - onSurface = GlanceTheme.colors.onSurface.dayNight(context, mode), - onSurfaceVariant = GlanceTheme.colors.onSurfaceVariant.dayNight(context, mode), - primary = GlanceTheme.colors.primary.dayNight(context, mode), - onPrimary = GlanceTheme.colors.onPrimary.dayNight(context, mode), + onSurface = remoteColor(WidgetColorRole.ON_SURFACE), + onSurfaceVariant = remoteColor(WidgetColorRole.ON_SURFACE_VARIANT), + primary = remoteColor(WidgetColorRole.PRIMARY), + onPrimary = remoteColor(WidgetColorRole.ON_PRIMARY), ) MonthHeader(label = monthLabel(ym, source.today.year), today = source.today) diff --git a/app/src/main/res/layout/widget_month_week.xml b/app/src/main/res/layout/widget_month_week.xml index e44c7d9..40bf2ff 100644 --- a/app/src/main/res/layout/widget_month_week.xml +++ b/app/src/main/res/layout/widget_month_week.xml @@ -12,37 +12,44 @@ android:id="@+id/strip_0" android:layout_width="0dp" android:layout_height="match_parent" - android:layout_weight="1" /> + android:layout_weight="1" + android:background="?android:attr/selectableItemBackground" /> + android:layout_weight="1" + android:background="?android:attr/selectableItemBackground" /> + android:layout_weight="1" + android:background="?android:attr/selectableItemBackground" /> + android:layout_weight="1" + android:background="?android:attr/selectableItemBackground" /> + android:layout_weight="1" + android:background="?android:attr/selectableItemBackground" /> + android:layout_weight="1" + android:background="?android:attr/selectableItemBackground" /> + android:layout_weight="1" + android:background="?android:attr/selectableItemBackground" /> Date: Fri, 23 Oct 2026 15:48:44 +0200 Subject: [PATCH 13/24] Pin floret-kit past its lint fix --- floret-kit | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/floret-kit b/floret-kit index 513c135..76e07fe 160000 --- a/floret-kit +++ b/floret-kit @@ -1 +1 @@ -Subproject commit 513c135ec2f7a41404d387a21a421c81f5e30008 +Subproject commit 76e07fe8cbf59dbee564c2413c969de358fd1b4b From 610409012fdbc1e1670660a6e99aa11bcf69937e Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Fri, 23 Oct 2026 17:59:34 +0200 Subject: [PATCH 14/24] Month widget: day cells and weekday pills, shaded outside days, new-event button (#71) --- .../calendula/widget/agenda/AgendaWidget.kt | 2 +- .../calendula/widget/month/MonthGridViews.kt | 19 +++ .../calendula/widget/month/MonthWidget.kt | 60 +++++-- .../res/drawable/widget_month_cell_bg.xml | 5 + app/src/main/res/layout/widget_month_week.xml | 147 +++++++++++++++++- floret-kit | 2 +- 6 files changed, 211 insertions(+), 24 deletions(-) create mode 100644 app/src/main/res/drawable/widget_month_cell_bg.xml 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 66bef32..500d823 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 @@ -295,7 +295,7 @@ private fun AgendaHeader() { WidgetHeader( title = context.getString(R.string.widget_agenda_title), titleAction = actionStartActivity(MainActivity.openViewIntent(context, view = null)), - modifier = GlanceModifier.padding(start = 4.dp), + titleInset = 4.dp, ) { WidgetHeaderAction( icon = ImageProvider(R.drawable.ic_widget_add), diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/widget/month/MonthGridViews.kt b/app/src/main/java/de/jeanlucmakiola/calendula/widget/month/MonthGridViews.kt index 29215f7..e8aaa41 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/widget/month/MonthGridViews.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/widget/month/MonthGridViews.kt @@ -9,6 +9,7 @@ import android.text.style.StrikethroughSpan import android.text.style.StyleSpan import android.view.View import android.widget.RemoteViews +import androidx.compose.ui.graphics.Color import de.jeanlucmakiola.calendula.MainActivity import de.jeanlucmakiola.calendula.R import de.jeanlucmakiola.calendula.domain.EventInstance @@ -30,6 +31,7 @@ internal data class GridColors( val onSurfaceVariant: RemoteColor, val primary: RemoteColor, val onPrimary: RemoteColor, + val cell: RemoteColor, ) /** @@ -69,6 +71,15 @@ private fun weekViews( week.removeAllViews(R.id.week_lanes) layout.week.days.forEachIndexed { col, date -> week.setOnClickPendingIntent(STRIP_IDS[col], openDayIntent(context, date)) + // Each day its own rounded cell, as in the app's month grid. Days around + // the month get a shade over their (opaque) cell, so they recede without + // letting a wallpaper show through. + week.setImageTint(context, CELL_IDS[col], colors.cell) + val outside = date.month != currentMonth + week.setViewVisibility(CELL_DIM_IDS[col], if (outside) View.VISIBLE else View.GONE) + if (outside) { + week.setImageTint(context, CELL_DIM_IDS[col], mode.remoteColor(OUTSIDE_SHADE_DAY, OUTSIDE_SHADE_NIGHT)) + } val dayId = DAY_IDS[col] val isToday = date == today week.setTextViewText(dayId, if (isToday) bold(date.day.toString()) else date.day.toString()) @@ -136,6 +147,14 @@ private fun openDayIntent(context: Context, date: LocalDate): PendingIntent = Pe ) private val STRIP_IDS = intArrayOf(R.id.strip_0, R.id.strip_1, R.id.strip_2, R.id.strip_3, R.id.strip_4, R.id.strip_5, R.id.strip_6) +/** The shade over a day outside the month: a dark cell needs more of it to read as dimmed. */ +private val OUTSIDE_SHADE_DAY = Color.Black.copy(alpha = 0.18f) +private val OUTSIDE_SHADE_NIGHT = Color.Black.copy(alpha = 0.50f) + +private val CELL_DIM_IDS = intArrayOf( + R.id.cell_dim_0, R.id.cell_dim_1, R.id.cell_dim_2, R.id.cell_dim_3, R.id.cell_dim_4, R.id.cell_dim_5, R.id.cell_dim_6, +) +private val CELL_IDS = intArrayOf(R.id.cell_0, R.id.cell_1, R.id.cell_2, R.id.cell_3, R.id.cell_4, R.id.cell_5, R.id.cell_6) private val DAY_IDS = intArrayOf(R.id.day_0, R.id.day_1, R.id.day_2, R.id.day_3, R.id.day_4, R.id.day_5, R.id.day_6) private val TODAY_IDS = intArrayOf( R.id.today_bg_0, R.id.today_bg_1, R.id.today_bg_2, R.id.today_bg_3, R.id.today_bg_4, R.id.today_bg_5, R.id.today_bg_6, 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 8ce7fe8..2b49d70 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 @@ -21,6 +21,10 @@ import androidx.glance.action.ActionParameters import androidx.glance.action.actionParametersOf import androidx.glance.appwidget.GlanceAppWidget import androidx.glance.appwidget.AndroidRemoteViews +import de.jeanlucmakiola.floret.glance.WidgetBackground +import de.jeanlucmakiola.floret.glance.LocalWidgetBackground +import androidx.glance.background +import androidx.glance.appwidget.cornerRadius import androidx.glance.appwidget.SizeMode import androidx.glance.appwidget.action.ActionCallback import androidx.glance.appwidget.action.actionRunCallback @@ -88,11 +92,11 @@ private val GRID_HPADDING = 8.dp /** * Height around the week rows: body padding, the header's bottom padding and - * spacer, then the header (its 28dp pills, or a title line in its transparent + * spacer and the weekday pills' padding, then the header (its 28dp pills, or a title line in its transparent * pill when that's taller) and the weekday row, the text lines at [fontScale]. */ private fun gridChromeHeight(fontScale: Float): Dp = - 18.dp + maxOf(28.dp, 19.dp * fontScale + 8.dp) + 15.dp * fontScale + 21.dp + maxOf(28.dp, 19.dp * fontScale + 8.dp) + 15.dp * fontScale private fun currentMonthIndex(zone: TimeZone): Int = monthIndexOf(today(zone)) @@ -248,12 +252,15 @@ private fun MonthWidgetBody(source: MonthWidgetSource, index: Int) { onSurfaceVariant = remoteColor(WidgetColorRole.ON_SURFACE_VARIANT), primary = remoteColor(WidgetColorRole.PRIMARY), onPrimary = remoteColor(WidgetColorRole.ON_PRIMARY), + cell = remoteColor(monthCellRole()), ) MonthHeader(label = monthLabel(ym, source.today.year), today = source.today) Spacer(GlanceModifier.height(4.dp)) - // One backing for the whole grid on a transparent widget (#292). - Column(modifier = GlanceModifier.fillMaxWidth().defaultWeight().wallpaperBacking()) { + // No backing behind the grid: the weekday pills and day cells + // each carry their own fill, so a transparent widget stays + // readable without one blob over the wallpaper (#292). + Column(modifier = GlanceModifier.fillMaxWidth().defaultWeight()) { WeekdayHeader(weekStart = source.weekStart, colW = colW) AndroidRemoteViews( remoteViews = monthGridViews(context, layouts, ym.month, source.today, source.soften, colors, mode), @@ -273,7 +280,8 @@ private fun MonthHeader(label: String, today: LocalDate) { WidgetHeader( title = label, titleAction = actionStartActivity(MainActivity.openViewIntent(context, CalendarView.Month)), - modifier = GlanceModifier.padding(start = 4.dp, bottom = 2.dp), + modifier = GlanceModifier.padding(bottom = 2.dp), + titleInset = 4.dp, ) { WidgetHeaderAction( icon = ImageProvider(R.drawable.ic_widget_chevron_left), @@ -288,6 +296,7 @@ private fun MonthHeader(label: String, today: LocalDate) { text = today.day.toString(), contentDescription = context.getString(R.string.widget_today), action = actionRunCallback(), + style = WidgetActionStyle.TONAL, ) WidgetHeaderActionGap() WidgetHeaderAction( @@ -296,26 +305,47 @@ private fun MonthHeader(label: String, today: LocalDate) { action = actionRunCallback(actionParametersOf(ShiftMonthAction.deltaKey to 1)), style = WidgetActionStyle.TONAL, ) + WidgetHeaderActionGap() + WidgetHeaderAction( + icon = ImageProvider(R.drawable.ic_widget_add), + contentDescription = context.getString(R.string.widget_new_event), + action = actionStartActivity(MainActivity.openCreateIntent(context, today)), + ) } } @Composable private fun WeekdayHeader(weekStart: DayOfWeek, colW: Dp) { - Row(modifier = GlanceModifier.fillMaxWidth()) { + Row(modifier = GlanceModifier.fillMaxWidth().padding(bottom = 1.dp)) { weekdayNarrowNames(weekStart).forEach { name -> - Text( - text = name, - style = TextStyle( - color = GlanceTheme.colors.onSurfaceVariant, - fontSize = 11.sp, - textAlign = TextAlign.Center, - ), - modifier = GlanceModifier.width(colW), - ) + Box(modifier = GlanceModifier.width(colW).padding(horizontal = 1.dp)) { + Text( + text = name, + style = TextStyle( + color = GlanceTheme.colors.onPrimaryContainer, + fontSize = 11.sp, + textAlign = TextAlign.Center, + ), + modifier = GlanceModifier + .fillMaxWidth() + .cornerRadius(8.dp) + .background(GlanceTheme.colors.primaryContainer) + .padding(vertical = 1.dp), + ) + } } } } +/** The fill of a day cell: one tone off a filled root, the widget surface over a wallpaper. */ +private fun monthCellRole(background: WidgetBackground): WidgetColorRole = when (background) { + WidgetBackground.SOLID -> WidgetColorRole.SURFACE_VARIANT + WidgetBackground.TINTED, WidgetBackground.TRANSPARENT -> WidgetColorRole.WIDGET_BACKGROUND +} + +@Composable +private fun monthCellRole(): WidgetColorRole = monthCellRole(LocalWidgetBackground.current) + /** Narrow weekday initials starting at [weekStart], in the device locale. * Computed outside the composable so the locale read stays observable-safe. */ private fun weekdayNarrowNames(weekStart: DayOfWeek): List { diff --git a/app/src/main/res/drawable/widget_month_cell_bg.xml b/app/src/main/res/drawable/widget_month_cell_bg.xml new file mode 100644 index 0000000..34615d9 --- /dev/null +++ b/app/src/main/res/drawable/widget_month_cell_bg.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/app/src/main/res/layout/widget_month_week.xml b/app/src/main/res/layout/widget_month_week.xml index 40bf2ff..60942ab 100644 --- a/app/src/main/res/layout/widget_month_week.xml +++ b/app/src/main/res/layout/widget_month_week.xml @@ -13,43 +13,176 @@ android:layout_width="0dp" android:layout_height="match_parent" android:layout_weight="1" - android:background="?android:attr/selectableItemBackground" /> + android:foreground="?android:attr/selectableItemBackground" + android:padding="1dp"> + + + + + + android:foreground="?android:attr/selectableItemBackground" + android:padding="1dp"> + + + + + + android:foreground="?android:attr/selectableItemBackground" + android:padding="1dp"> + + + + + + android:foreground="?android:attr/selectableItemBackground" + android:padding="1dp"> + + + + + + android:foreground="?android:attr/selectableItemBackground" + android:padding="1dp"> + + + + + + android:foreground="?android:attr/selectableItemBackground" + android:padding="1dp"> + + + + + + android:foreground="?android:attr/selectableItemBackground" + android:padding="1dp"> + + + + + Date: Fri, 23 Oct 2026 18:19:48 +0200 Subject: [PATCH 15/24] Widgets: one kind registry and receiver base, per-widget calendar choice (#292) --- .../calendula/ui/settings/WidgetSettings.kt | 3 +- .../widget/CalendulaWidgetReceiver.kt | 44 +++++++ .../calendula/widget/WidgetConfig.kt | 17 +++ .../calendula/widget/WidgetData.kt | 29 +++-- .../calendula/widget/WidgetKind.kt | 57 +++++++++ .../calendula/widget/WidgetRedraw.kt | 51 +++------ .../widget/agenda/AgendaWidgetReceiver.kt | 51 +-------- .../calendula/widget/config/PlacedWidgets.kt | 1 + .../widget/config/WidgetCalendarPicker.kt | 60 ++++++++++ .../widget/config/WidgetConfigActivity.kt | 3 +- .../widget/config/WidgetConfigScreen.kt | 98 ++++++++++------ .../widget/config/WidgetConfigViewModel.kt | 108 ++++++++++-------- .../widget/month/MonthWidgetReceiver.kt | 53 +-------- app/src/main/res/values/strings.xml | 8 ++ 14 files changed, 358 insertions(+), 225 deletions(-) create mode 100644 app/src/main/java/de/jeanlucmakiola/calendula/widget/CalendulaWidgetReceiver.kt create mode 100644 app/src/main/java/de/jeanlucmakiola/calendula/widget/WidgetKind.kt create mode 100644 app/src/main/java/de/jeanlucmakiola/calendula/widget/config/WidgetCalendarPicker.kt diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/WidgetSettings.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/WidgetSettings.kt index aad1d35..c383197 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/WidgetSettings.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/WidgetSettings.kt @@ -29,11 +29,10 @@ 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 de.jeanlucmakiola.calendula.widget.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 diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/widget/CalendulaWidgetReceiver.kt b/app/src/main/java/de/jeanlucmakiola/calendula/widget/CalendulaWidgetReceiver.kt new file mode 100644 index 0000000..dbc12e0 --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/calendula/widget/CalendulaWidgetReceiver.kt @@ -0,0 +1,44 @@ +package de.jeanlucmakiola.calendula.widget + +import android.annotation.SuppressLint +import android.appwidget.AppWidgetManager +import android.content.Context +import androidx.glance.appwidget.GlanceAppWidgetReceiver +import de.jeanlucmakiola.floret.glance.reloadOnUpdate +import de.jeanlucmakiola.floret.glance.restoreState + +/** + * What every widget receiver shares: the midnight rollover follows whether any + * widget is placed (#228), a restore keeps each widget's settings (#292), and + * the system's `updatePeriodMillis` wake-up reloads the data and re-arms the + * alarm — anything that drops a pending alarm without a broadcast (force-stop, + * battery restriction, an OEM freeze) is repaired there rather than on the + * next app open. + */ +abstract class CalendulaWidgetReceiver : GlanceAppWidgetReceiver() { + + override fun onEnabled(context: Context) { + super.onEnabled(context) + syncWidgetRollover(context) + } + + override fun onDisabled(context: Context) { + super.onDisabled(context) + syncWidgetRollover(context) + } + + override fun onRestored(context: Context, oldWidgetIds: IntArray, newWidgetIds: IntArray) { + restoreState(context, oldWidgetIds, newWidgetIds) + } + + @SuppressLint("MissingSuperCall") + override fun onUpdate( + context: Context, + appWidgetManager: AppWidgetManager, + appWidgetIds: IntArray, + ) { + // 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/WidgetConfig.kt b/app/src/main/java/de/jeanlucmakiola/calendula/widget/WidgetConfig.kt index 69ac74d..32258b8 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/widget/WidgetConfig.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/widget/WidgetConfig.kt @@ -5,10 +5,12 @@ 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.datastore.preferences.core.stringSetPreferencesKey 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.domain.EventInstance import de.jeanlucmakiola.calendula.ui.agenda.AgendaRange import de.jeanlucmakiola.calendula.ui.agenda.parseAgendaRange import de.jeanlucmakiola.calendula.ui.agenda.storageValue @@ -50,6 +52,7 @@ 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") +private val HiddenCalendarsKey = stringSetPreferencesKey("cfg_hidden_calendars") internal val Preferences.widgetAppearance: WidgetAppearance get() = WidgetAppearance( @@ -84,5 +87,19 @@ internal fun MutablePreferences.putAgendaOptions(options: AgendaWidgetOptions) { this[PastDisplayKey] = options.pastDisplay.name } +/** + * Calendars this widget leaves out, on top of the ones hidden app-wide. Stored + * as the excluded set, so a calendar added later shows up by default. + */ +internal val Preferences.hiddenCalendars: Set + get() = this[HiddenCalendarsKey]?.mapNotNull { it.toLongOrNull() }?.toSet().orEmpty() + +internal fun MutablePreferences.putHiddenCalendars(ids: Set) { + this[HiddenCalendarsKey] = ids.map(Long::toString).toSet() +} + +internal fun List.withoutCalendars(hidden: Set): List = + if (hidden.isEmpty()) this else filterNot { it.calendarId in hidden } + internal suspend fun Context.widgetState(id: GlanceId): Preferences = getAppWidgetState(this, PreferencesGlanceStateDefinition, id) diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/widget/WidgetData.kt b/app/src/main/java/de/jeanlucmakiola/calendula/widget/WidgetData.kt index dca5a54..61a0725 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/widget/WidgetData.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/widget/WidgetData.kt @@ -96,6 +96,12 @@ internal val SettingsPrefs.widgetSettings: Flow internal fun systemZone(): TimeZone = TimeZone.currentSystemDefault() +/** + * The locale widget text is written in. Glance has no LocalConfiguration, and a + * widget is redrawn on a locale change anyway (LOCALE_CHANGED), so a plain read is enough. + */ +internal fun widgetLocale(): Locale = Locale.getDefault() + internal fun today(zone: TimeZone): LocalDate = Clock.System.now().toLocalDateTime(zone).date @@ -145,8 +151,9 @@ sealed interface MonthWidgetSource { /** [loadAgendaWidgetData] with agenda widget [id]'s own options. */ internal suspend fun Context.loadAgendaWidget(id: GlanceId): Themed { val settings = widgetEntryPoint().settingsPrefs().widgetSettings.first() - val options = widgetState(id).agendaWidgetOptions(settings.agendaDefaults) - return Themed(loadAgendaWidgetData(settings, options), settings.themeMode) + val state = widgetState(id) + val options = state.agendaWidgetOptions(settings.agendaDefaults) + return Themed(loadAgendaWidgetData(settings, options, state.hiddenCalendars), settings.themeMode) } /** @@ -157,6 +164,7 @@ internal suspend fun Context.loadAgendaWidget(id: GlanceId): Themed, ): AgendaWidgetData { if (!hasCalendarPermission()) return AgendaWidgetData.NeedsPermission val zone = systemZone() @@ -166,7 +174,7 @@ internal suspend fun Context.loadAgendaWidgetData( 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() + val instances = ep.calendarRepository().instances(window).first().withoutCalendars(hiddenCalendars) return AgendaWidgetData.Ready( today = anchor, days = groupAgendaDays(anchor, windowEnd, instances, zone), @@ -214,15 +222,20 @@ private object MonthSourceCache { /** [loadThemedMonthSource] for widget [id], served from [MonthSourceCache] while its [stamp] holds. */ internal suspend fun Context.loadCachedMonthSource(id: GlanceId, centerIndex: Int, stamp: Long): Themed = MonthSourceCache.get(id, centerIndex, stamp) - ?: loadThemedMonthSource(centerIndex).also { MonthSourceCache.put(id, centerIndex, stamp, it) } + ?: loadThemedMonthSource(centerIndex, widgetState(id).hiddenCalendars) + .also { MonthSourceCache.put(id, centerIndex, stamp, it) } /** Read backing the month widget's grid for the window around [centerIndex], with the app theme. */ -internal suspend fun Context.loadThemedMonthSource(centerIndex: Int): Themed { +internal suspend fun Context.loadThemedMonthSource(centerIndex: Int, hiddenCalendars: Set): Themed { val settings = widgetEntryPoint().settingsPrefs().widgetSettings.first() - return Themed(queryMonthSource(centerIndex, settings), settings.themeMode) + return Themed(queryMonthSource(centerIndex, settings, hiddenCalendars), settings.themeMode) } -private suspend fun Context.queryMonthSource(centerIndex: Int, settings: WidgetSettings): MonthWidgetSource { +private suspend fun Context.queryMonthSource( + centerIndex: Int, + settings: WidgetSettings, + hiddenCalendars: Set, +): MonthWidgetSource { if (!hasCalendarPermission()) return MonthWidgetSource.NeedsPermission val zone = systemZone() val ep = widgetEntryPoint() @@ -239,7 +252,7 @@ private suspend fun Context.queryMonthSource(centerIndex: Int, settings: WidgetS today = today(zone), weekStart = settings.weekStart.resolveFirstDay(Locale.getDefault()), soften = settings.soften, - instances = ep.calendarRepository().instances(from..to).first(), + instances = ep.calendarRepository().instances(from..to).first().withoutCalendars(hiddenCalendars), months = months, ) } diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/widget/WidgetKind.kt b/app/src/main/java/de/jeanlucmakiola/calendula/widget/WidgetKind.kt new file mode 100644 index 0000000..8cbb078 --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/calendula/widget/WidgetKind.kt @@ -0,0 +1,57 @@ +package de.jeanlucmakiola.calendula.widget + +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 de.jeanlucmakiola.calendula.R +import de.jeanlucmakiola.calendula.widget.agenda.AgendaWidget +import de.jeanlucmakiola.calendula.widget.agenda.AgendaWidgetReceiver +import de.jeanlucmakiola.calendula.widget.month.MonthWidget +import de.jeanlucmakiola.calendula.widget.month.MonthWidgetReceiver + +/** A content option a widget's config screen offers, besides background and theme. */ +enum class WidgetOption { RANGE, TEXT_SIZE, PAST_DISPLAY, SHOW_TODAY, CALENDARS } + +/** + * Every widget the app ships, told apart by their receiver: what redraws, + * the rollover and the config screen go through. + */ +enum class WidgetKind( + val receiver: Class<*>, + val label: Int, + val options: Set, + /** The size a config preview draws at before the launcher reports one. */ + val defaultSize: DpSize, +) { + AGENDA( + AgendaWidgetReceiver::class.java, + R.string.widget_agenda_label, + setOf(WidgetOption.RANGE, WidgetOption.TEXT_SIZE, WidgetOption.PAST_DISPLAY, WidgetOption.SHOW_TODAY, WidgetOption.CALENDARS), + DpSize(260.dp, 280.dp), + ), + MONTH( + MonthWidgetReceiver::class.java, + R.string.widget_month_label, + setOf(WidgetOption.CALENDARS), + DpSize(320.dp, 340.dp), + ), + ; + + fun widget(): GlanceAppWidget = when (this) { + AGENDA -> AgendaWidget() + MONTH -> MonthWidget() + } + + /** Whether this kind stores [AgendaWidgetOptions] of its own. */ + val hasContentOptions: Boolean + get() = options.any { it != WidgetOption.CALENDARS } + + 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 } + } + } +} 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 5425900..ec8bd90 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/widget/WidgetRedraw.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/widget/WidgetRedraw.kt @@ -9,10 +9,6 @@ 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 -import de.jeanlucmakiola.calendula.widget.agenda.AgendaWidget -import de.jeanlucmakiola.calendula.widget.agenda.AgendaWidgetReceiver -import de.jeanlucmakiola.calendula.widget.month.MonthWidget -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 @@ -33,10 +29,7 @@ import javax.inject.Inject import javax.inject.Singleton /** Every widget receiver; the set [WidgetRollover] checks for placed instances. */ -internal val WIDGET_RECEIVER_CLASSES: List> = listOf( - MonthWidgetReceiver::class.java, - AgendaWidgetReceiver::class.java, -) +internal val WIDGET_RECEIVER_CLASSES: List> = WidgetKind.entries.map { it.receiver } /** The app's own midnight wake-up (#228). */ internal const val ACTION_WIDGET_ROLLOVER = "de.jeanlucmakiola.calendula.widget.ROLLOVER" @@ -57,31 +50,28 @@ internal fun syncWidgetRollover(context: Context) = /** * Reloads and redraws every placed widget, a running session included: the - * refresh stamp moves, and both widgets key their data load on it (#420). + * refresh stamp moves, and every widget keys its data load on it (#420). */ internal suspend fun redrawWidgets(context: Context) { - redrawAgendaWidgets(context) - redrawMonthWidgets(context) + WidgetKind.entries.forEach { redrawWidgets(context, it) } } -private suspend fun redrawAgendaWidgets(context: Context) = - AgendaWidget().updatePlaced(context, AgendaWidgetReceiver::class.java, bumpRefreshStamp = true) +internal suspend fun redrawWidgets(context: Context, kind: WidgetKind) = + kind.widget().updatePlaced(context, kind.receiver, 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) { +/** Redraws only the widgets with no content options of their own, which still follow the app's. */ +private suspend fun redrawUnconfiguredWidgets(context: Context) { val appContext = context.applicationContext - val ids = AppWidgetManager.getInstance(appContext) - ?.getAppWidgetIds(ComponentName(appContext, AgendaWidgetReceiver::class.java)) ?: return + val manager = AppWidgetManager.getInstance(appContext) ?: return val glanceManager = GlanceAppWidgetManager(appContext) - ids.forEach { appWidgetId -> - val glanceId = glanceManager.getGlanceIdBy(appWidgetId) - if (!appContext.widgetState(glanceId).hasAgendaOptions) AgendaWidget().updateWithRefresh(appContext, glanceId) + WidgetKind.entries.filter { it.hasContentOptions }.forEach { kind -> + manager.getAppWidgetIds(ComponentName(appContext, kind.receiver)).forEach { appWidgetId -> + val glanceId = glanceManager.getGlanceIdBy(appWidgetId) + if (!appContext.widgetState(glanceId).hasAgendaOptions) kind.widget().updateWithRefresh(appContext, glanceId) + } } } -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) @@ -97,7 +87,7 @@ internal fun isProviderEcho(): Boolean { * 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. The app's agenda options only redraw - * agenda widgets that have none of their own (#292). Started once from + * widgets that have no content options of their own (#292). Started once from * `CalendulaApp`. */ @Singleton @@ -112,20 +102,17 @@ class WidgetRefreshCoordinator @Inject constructor( /** Subscribes before returning, so a change made right after is never taken for the starting value. */ 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) } + val all = WidgetKind.entries.map { kind -> WidgetRefresher(scope) { redrawWidgets(context, kind) } } + val unconfigured = WidgetRefresher(scope) { redrawUnconfiguredWidgets(context) } watch(repository.localChanges) { lastLocalChange.set(SystemClock.elapsedRealtime()) - agenda.request() - month.request() + all.forEach { it.request() } } watch(prefs.widgetSettings.map { it.shared }.distinctUntilChanged().drop(1)) { - agenda.request() - month.request() + all.forEach { it.request() } } watch(prefs.widgetSettings.map { it.agendaDefaults }.distinctUntilChanged().drop(1)) { - unconfiguredAgenda.request() + unconfigured.request() } } 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 ee48a12..01eab36 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,54 +1,9 @@ package de.jeanlucmakiola.calendula.widget.agenda -import android.annotation.SuppressLint -import android.appwidget.AppWidgetManager -import android.content.Context 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 +import de.jeanlucmakiola.calendula.widget.CalendulaWidgetReceiver -/** - * Host-facing receiver for the agenda widget. Declared in the manifest with the - * `appwidget_info_agenda` provider metadata; delegates all rendering to - * [AgendaWidget]. - */ -class AgendaWidgetReceiver : GlanceAppWidgetReceiver() { +/** Host-facing receiver for the agenda widget; rendering is [AgendaWidget]'s. */ +class AgendaWidgetReceiver : CalendulaWidgetReceiver() { override val glanceAppWidget: GlanceAppWidget = AgendaWidget() - - /** First agenda widget placed — start rolling "today" over at midnight (#228). */ - override fun onEnabled(context: Context) { - super.onEnabled(context) - syncWidgetRollover(context) - } - - /** - * Last agenda widget removed. [syncWidgetRollover] cancels only if - * no month widget is left either. - */ - override fun onDisabled(context: Context) { - super.onDisabled(context) - 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]. - */ - @SuppressLint("MissingSuperCall") - override fun onUpdate( - context: Context, - appWidgetManager: AppWidgetManager, - appWidgetIds: IntArray, - ) { - // 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/config/PlacedWidgets.kt b/app/src/main/java/de/jeanlucmakiola/calendula/widget/config/PlacedWidgets.kt index 4425ac4..458e060 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/widget/config/PlacedWidgets.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/widget/config/PlacedWidgets.kt @@ -5,6 +5,7 @@ import android.content.ComponentName import android.content.Context import androidx.glance.appwidget.GlanceAppWidgetManager import de.jeanlucmakiola.calendula.widget.WidgetAppearance +import de.jeanlucmakiola.calendula.widget.WidgetKind import de.jeanlucmakiola.calendula.widget.widgetAppearance import de.jeanlucmakiola.calendula.widget.widgetState diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/widget/config/WidgetCalendarPicker.kt b/app/src/main/java/de/jeanlucmakiola/calendula/widget/config/WidgetCalendarPicker.kt new file mode 100644 index 0000000..a9122e6 --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/calendula/widget/config/WidgetCalendarPicker.kt @@ -0,0 +1,60 @@ +package de.jeanlucmakiola.calendula.widget.config + +import androidx.compose.material3.Checkbox +import androidx.compose.runtime.Composable +import androidx.compose.ui.res.pluralStringResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import de.jeanlucmakiola.calendula.R +import de.jeanlucmakiola.calendula.domain.CalendarSource +import de.jeanlucmakiola.calendula.ui.common.CalendarColorChip +import de.jeanlucmakiola.calendula.ui.common.accountGroupTitle +import de.jeanlucmakiola.calendula.ui.common.groupByAccount +import de.jeanlucmakiola.calendula.ui.common.orUnnamed +import de.jeanlucmakiola.floret.components.FullScreenPicker +import de.jeanlucmakiola.floret.components.GroupedHint +import de.jeanlucmakiola.floret.components.GroupedRow +import de.jeanlucmakiola.floret.components.GroupedSectionHeader +import de.jeanlucmakiola.floret.components.positionOf + +/** Which of the app's calendars one widget shows, grouped by account like the drawer's filter. */ +@Composable +internal fun WidgetCalendarPicker( + calendars: List, + hidden: Set, + onSetShown: (Long, Boolean) -> Unit, + onDismiss: () -> Unit, +) { + FullScreenPicker( + title = stringResource(R.string.widget_config_calendars), + onDismiss = onDismiss, + predictiveBack = true, + ) { + GroupedHint(stringResource(R.string.widget_config_calendars_hint), verticalPadding = 8.dp) + calendars.groupByAccount().forEach { group -> + GroupedSectionHeader(accountGroupTitle(group)) + group.calendars.forEachIndexed { index, calendar -> + val shown = calendar.id !in hidden + GroupedRow( + title = calendar.displayName.orUnnamed(), + position = positionOf(index, group.calendars.size), + minHeight = 56.dp, + leading = { CalendarColorChip(calendar.color) }, + trailing = { Checkbox(checked = shown, onCheckedChange = { onSetShown(calendar.id, it) }) }, + onClick = { onSetShown(calendar.id, !shown) }, + ) + } + } + } +} + +/** "All calendars", or how many of them the widget shows. */ +@Composable +internal fun calendarsSummary(calendars: List, hidden: Set): String { + val shown = calendars.count { it.id !in hidden } + return if (shown == calendars.size) { + stringResource(R.string.widget_config_calendars_all) + } else { + pluralStringResource(R.plurals.widget_config_calendars_some, calendars.size, shown, calendars.size) + } +} diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/widget/config/WidgetConfigActivity.kt b/app/src/main/java/de/jeanlucmakiola/calendula/widget/config/WidgetConfigActivity.kt index 12cddfb..bf8fafa 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/widget/config/WidgetConfigActivity.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/widget/config/WidgetConfigActivity.kt @@ -113,7 +113,8 @@ class WidgetConfigActivity : ComponentActivity() { onSave = viewModel::save, onRetry = viewModel::retry, onAppearance = viewModel::setAppearance, - onAgendaOptions = viewModel::setAgendaOptions, + onContent = viewModel::setContent, + onCalendarShown = viewModel::setCalendarShown, ) } } diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/widget/config/WidgetConfigScreen.kt b/app/src/main/java/de/jeanlucmakiola/calendula/widget/config/WidgetConfigScreen.kt index 603e29e..004eac0 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/widget/config/WidgetConfigScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/widget/config/WidgetConfigScreen.kt @@ -42,6 +42,7 @@ 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.WidgetOption import de.jeanlucmakiola.calendula.widget.WidgetSize import de.jeanlucmakiola.calendula.widget.WidgetTheme import de.jeanlucmakiola.calendula.widget.agenda.AgendaWidgetContent @@ -54,6 +55,7 @@ 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.components.positionOf import de.jeanlucmakiola.floret.glance.GlanceWidgetPreview import de.jeanlucmakiola.floret.glance.WallpaperWindow import de.jeanlucmakiola.floret.glance.WidgetBackground @@ -69,7 +71,8 @@ internal fun WidgetConfigScreen( onSave: () -> Unit, onRetry: () -> Unit, onAppearance: (WidgetAppearance) -> Unit, - onAgendaOptions: (AgendaWidgetOptions) -> Unit, + onContent: (AgendaWidgetOptions) -> Unit, + onCalendarShown: (Long, Boolean) -> Unit, ) { val ready = state as? WidgetConfigUiState.Ready val surface = MaterialTheme.colorScheme.surface @@ -150,7 +153,8 @@ internal fun WidgetConfigScreen( ConfigOptions( state = state, onAppearance = onAppearance, - onAgendaOptions = onAgendaOptions, + onContent = onContent, + onCalendarShown = onCalendarShown, modifier = Modifier .weight(1f) .background(surface), @@ -173,17 +177,12 @@ private fun PreviewFailed() { ) } -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, + onContent: (AgendaWidgetOptions) -> Unit, + onCalendarShown: (Long, Boolean) -> Unit, modifier: Modifier = Modifier, ) { Column( @@ -209,47 +208,72 @@ private fun ConfigOptions( onSelect = { onAppearance(state.appearance.copy(theme = it)) }, position = Position.Bottom, ) - state.agenda?.let { options -> - AgendaOptions(options = options, state = state, onChange = onAgendaOptions) + if (state.kind.options.isNotEmpty()) { + ContentOptions(state = state, onChange = onContent, onCalendarShown = onCalendarShown) } } } @Composable -private fun AgendaOptions( - options: AgendaWidgetOptions, +private fun ContentOptions( state: WidgetConfigUiState.Ready, onChange: (AgendaWidgetOptions) -> Unit, + onCalendarShown: (Long, Boolean) -> Unit, ) { + val options = state.draft.content var showRange by rememberSaveable { mutableStateOf(false) } var showSize by rememberSaveable { mutableStateOf(false) } var showPast by rememberSaveable { mutableStateOf(false) } + var showCalendars by rememberSaveable { mutableStateOf(false) } + + // Rows in WidgetOption order, each shaped by where it lands among the kind's own. + val rows = WidgetOption.entries.filter { it in state.kind.options } + fun position(option: WidgetOption) = positionOf(rows.indexOf(option), rows.size) 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, - ) + rows.forEach { option -> + when (option) { + WidgetOption.RANGE -> GroupedRow( + title = stringResource(R.string.widget_config_range), + summary = agendaRangeLabel(options.range), + position = position(option), + onClick = { showRange = true }, + ) + WidgetOption.TEXT_SIZE -> GroupedRow( + title = stringResource(R.string.widget_config_text_size), + summary = widgetSizeLabel(options.textSize), + position = position(option), + onClick = { showSize = true }, + ) + WidgetOption.PAST_DISPLAY -> GroupedRow( + title = stringResource(R.string.settings_past_events), + summary = stringResource(pastEventDisplayLabelRes(options.pastDisplay)), + position = position(option), + onClick = { showPast = true }, + ) + WidgetOption.SHOW_TODAY -> GroupedSwitchRow( + title = stringResource(R.string.settings_agenda_show_today), + checked = options.showToday, + onCheckedChange = { onChange(options.copy(showToday = it)) }, + position = position(option), + ) + WidgetOption.CALENDARS -> GroupedRow( + title = stringResource(R.string.widget_config_calendars), + summary = calendarsSummary(state.calendars, state.draft.hiddenCalendars), + position = position(option), + onClick = { showCalendars = true }, + ) + } + } + + if (showCalendars) { + WidgetCalendarPicker( + calendars = state.calendars, + hidden = state.draft.hiddenCalendars, + onSetShown = onCalendarShown, + onDismiss = { showCalendars = false }, + ) + } if (showRange) { AgendaRangePicker( diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/widget/config/WidgetConfigViewModel.kt b/app/src/main/java/de/jeanlucmakiola/calendula/widget/config/WidgetConfigViewModel.kt index 71ac45a..696af8b 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/widget/config/WidgetConfigViewModel.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/widget/config/WidgetConfigViewModel.kt @@ -4,25 +4,27 @@ 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.calendar.CalendarRepository +import de.jeanlucmakiola.calendula.data.prefs.CalendarPrefs import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs +import de.jeanlucmakiola.calendula.domain.CalendarSource +import de.jeanlucmakiola.calendula.widget.WidgetKind +import de.jeanlucmakiola.calendula.widget.WidgetOption +import de.jeanlucmakiola.calendula.widget.hiddenCalendars +import de.jeanlucmakiola.calendula.widget.putHiddenCalendars 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 @@ -50,31 +52,20 @@ 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 } +/** The options being edited, applied to the preview as they change and written on save. */ +data class WidgetDraft( + val appearance: WidgetAppearance, + /** The content options; stored only for a kind that [WidgetKind.hasContentOptions]. */ + val content: AgendaWidgetOptions, + val hiddenCalendars: Set, +) + sealed interface WidgetConfigUiState { data object Loading : WidgetConfigUiState @@ -86,9 +77,9 @@ sealed interface WidgetConfigUiState { data class Ready( val kind: WidgetKind, - val appearance: WidgetAppearance, - /** Null for a widget with no content options. */ - val agenda: AgendaWidgetOptions?, + val draft: WidgetDraft, + /** The calendars the app shows, for the widget's own calendar choice. */ + val calendars: List, val appTheme: ThemeMode, val weekStart: DayOfWeek, /** The widget's size on the home screen, which the preview is laid out at. */ @@ -98,7 +89,9 @@ sealed interface WidgetConfigUiState { /** The first preview load failed, so there's nothing to show. */ val previewFailed: Boolean = false, val saving: Boolean = false, - ) : WidgetConfigUiState + ) : WidgetConfigUiState { + val appearance: WidgetAppearance get() = draft.appearance + } } /** One-time outcomes of a save, for the screen that's showing when they land. */ @@ -113,6 +106,8 @@ enum class WidgetConfigEvent { SAVED, SAVE_FAILED } class WidgetConfigViewModel @Inject constructor( @ApplicationContext private val context: Context, private val prefs: SettingsPrefs, + private val calendarPrefs: CalendarPrefs, + private val repository: CalendarRepository, savedState: SavedStateHandle, ) : ViewModel() { @@ -147,8 +142,12 @@ class WidgetConfigViewModel @Inject constructor( val settings = prefs.widgetSettings.first() WidgetConfigUiState.Ready( kind = kind, - appearance = stored.widgetAppearance, - agenda = if (kind == WidgetKind.AGENDA) stored.agendaWidgetOptions(settings.agendaDefaults) else null, + draft = WidgetDraft( + appearance = stored.widgetAppearance, + content = stored.agendaWidgetOptions(settings.agendaDefaults), + hiddenCalendars = stored.hiddenCalendars, + ), + calendars = if (WidgetOption.CALENDARS in kind.options) shownCalendars() else emptyList(), appTheme = settings.themeMode, weekStart = settings.weekStart.resolveFirstDay(Locale.getDefault()), placedSize = placedSize(kind), @@ -171,16 +170,27 @@ class WidgetConfigViewModel @Inject constructor( 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) - } + return kind.defaultSize } - fun setAppearance(appearance: WidgetAppearance) = edit { it.copy(appearance = appearance) } + /** The calendars the app itself shows; the ones hidden there never reach a widget anyway. */ + private suspend fun shownCalendars(): List { + val hidden = calendarPrefs.hiddenCalendarIds.first() + return repository.calendars().first().filter { it.isVisibleInSystem && it.id !in hidden } + } - fun setAgendaOptions(options: AgendaWidgetOptions) { - edit { it.copy(agenda = options) } + fun setAppearance(appearance: WidgetAppearance) = edit { it.copy(draft = it.draft.copy(appearance = appearance)) } + + fun setContent(options: AgendaWidgetOptions) { + edit { it.copy(draft = it.draft.copy(content = options)) } + reloadPreview() + } + + fun setCalendarShown(id: Long, shown: Boolean) { + edit { + val hidden = it.draft.hiddenCalendars + it.copy(draft = it.draft.copy(hiddenCalendars = if (shown) hidden - id else hidden + id)) + } reloadPreview() } @@ -205,14 +215,17 @@ class WidgetConfigViewModel @Inject constructor( } } - 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) + private suspend fun loadPreview(ready: WidgetConfigUiState.Ready): WidgetPreviewData { + val draft = ready.draft + val settings = prefs.widgetSettings.first() + return when (ready.kind) { + WidgetKind.AGENDA -> + WidgetPreviewData.Agenda(context.loadAgendaWidgetData(settings, draft.content, draft.hiddenCalendars)) + WidgetKind.MONTH -> { + val index = monthIndexOf(today(systemZone())) + val source = context.loadThemedMonthSource(monthWindowCenter(index), draft.hiddenCalendars) + WidgetPreviewData.Month(source.content, index) + } } } @@ -225,8 +238,9 @@ class WidgetConfigViewModel @Inject constructor( try { val glanceId = GlanceAppWidgetManager(context).getGlanceIdBy(appWidgetId) ready.kind.widget().updateWithRefresh(context, glanceId) { state -> - state.putAppearance(ready.appearance) - ready.agenda?.let { state.putAgendaOptions(it) } + state.putAppearance(ready.draft.appearance) + if (ready.kind.hasContentOptions) state.putAgendaOptions(ready.draft.content) + if (WidgetOption.CALENDARS in ready.kind.options) state.putHiddenCalendars(ready.draft.hiddenCalendars) } _events.send(WidgetConfigEvent.SAVED) } catch (e: CancellationException) { 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 7df07dc..f645a15 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,56 +1,9 @@ package de.jeanlucmakiola.calendula.widget.month -import android.annotation.SuppressLint -import android.appwidget.AppWidgetManager -import android.content.Context 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 +import de.jeanlucmakiola.calendula.widget.CalendulaWidgetReceiver -/** - * Host-facing receiver for the month widget. Declared in the manifest with the - * `appwidget_info_month` provider metadata; delegates rendering to [MonthWidget]. - */ -class MonthWidgetReceiver : GlanceAppWidgetReceiver() { +/** Host-facing receiver for the month widget; rendering is [MonthWidget]'s. */ +class MonthWidgetReceiver : CalendulaWidgetReceiver() { override val glanceAppWidget: GlanceAppWidget = MonthWidget() - - /** First month widget placed — start rolling "today" over at midnight (#228). */ - override fun onEnabled(context: Context) { - super.onEnabled(context) - syncWidgetRollover(context) - } - - /** - * Last month widget removed. [syncWidgetRollover] cancels only if - * no agenda widget is left either. - */ - override fun onDisabled(context: Context) { - super.onDisabled(context) - 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 - * pending alarm without a broadcast (force-stop, battery restriction, an OEM - * 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, - ) { - // In place of super, which only recomposes and would take the goAsync handle. - reloadOnUpdate(context, appWidgetIds) - syncWidgetRollover(context) - } } diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index b778d26..4fe6fa1 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -456,6 +456,14 @@ %1$s %2$d %1$s · %2$s + Calendars + The calendars this widget shows. Calendars you hid in the app stay hidden here too. + All calendars + + + %1$d of %2$d calendar + %1$d of %2$d calendars + Calendars From 85d4dc535c5ecc93faa25741fb08682c67e0be9f Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Fri, 23 Oct 2026 18:21:02 +0200 Subject: [PATCH 16/24] =?UTF-8?q?Quick=20add=20widget:=201=C3=971=20shaped?= =?UTF-8?q?=20+=20opening=20the=20editor=20on=20today=20(#435)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/src/main/AndroidManifest.xml | 12 +++ .../calendula/widget/WidgetData.kt | 4 + .../calendula/widget/WidgetKind.kt | 9 ++ .../widget/config/WidgetConfigScreen.kt | 3 + .../widget/config/WidgetConfigViewModel.kt | 3 + .../widget/quickadd/QuickAddWidget.kt | 84 +++++++++++++++++++ .../res/layout/widget_preview_quick_add.xml | 26 ++++++ app/src/main/res/values/strings.xml | 1 + .../main/res/xml/appwidget_info_quick_add.xml | 14 ++++ 9 files changed, 156 insertions(+) create mode 100644 app/src/main/java/de/jeanlucmakiola/calendula/widget/quickadd/QuickAddWidget.kt create mode 100644 app/src/main/res/layout/widget_preview_quick_add.xml create mode 100644 app/src/main/res/xml/appwidget_info_quick_add.xml diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 44143a5..e904f9e 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -352,6 +352,18 @@ android:resource="@xml/appwidget_info_month" /> + + + + + + + + + + + + + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 4fe6fa1..2f11d8a 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -435,6 +435,7 @@ Upcoming Calendula agenda Calendula month + Calendula quick add Refresh New event Open Calendula to grant calendar access diff --git a/app/src/main/res/xml/appwidget_info_quick_add.xml b/app/src/main/res/xml/appwidget_info_quick_add.xml new file mode 100644 index 0000000..933ce6d --- /dev/null +++ b/app/src/main/res/xml/appwidget_info_quick_add.xml @@ -0,0 +1,14 @@ + + From cfcba283f1c610a8e208a08e27bdee2678408955 Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Fri, 23 Oct 2026 18:22:03 +0200 Subject: [PATCH 17/24] =?UTF-8?q?Date=20widget:=201=C3=971=20weekday=20and?= =?UTF-8?q?=20day=20number=20on=20a=20cookie=20shape,=20opens=20today=20(#?= =?UTF-8?q?376)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/src/main/AndroidManifest.xml | 12 ++ .../calendula/widget/WidgetKind.kt | 9 ++ .../widget/config/WidgetConfigScreen.kt | 7 +- .../widget/config/WidgetConfigViewModel.kt | 2 +- .../calendula/widget/date/DateWidget.kt | 122 ++++++++++++++++++ .../main/res/layout/widget_preview_date.xml | 43 ++++++ app/src/main/res/values/strings.xml | 1 + app/src/main/res/xml/appwidget_info_date.xml | 14 ++ 8 files changed, 208 insertions(+), 2 deletions(-) create mode 100644 app/src/main/java/de/jeanlucmakiola/calendula/widget/date/DateWidget.kt create mode 100644 app/src/main/res/layout/widget_preview_date.xml create mode 100644 app/src/main/res/xml/appwidget_info_date.xml diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index e904f9e..292223d 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -364,6 +364,18 @@ android:resource="@xml/appwidget_info_quick_add" /> + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 2f11d8a..0458ecd 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -436,6 +436,7 @@ Calendula agenda Calendula month Calendula quick add + Calendula date Refresh New event Open Calendula to grant calendar access diff --git a/app/src/main/res/xml/appwidget_info_date.xml b/app/src/main/res/xml/appwidget_info_date.xml new file mode 100644 index 0000000..10b8368 --- /dev/null +++ b/app/src/main/res/xml/appwidget_info_date.xml @@ -0,0 +1,14 @@ + + From a8da8bc7a3380efb016a15d4be2fe329efb5c0c1 Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Fri, 23 Oct 2026 18:24:09 +0200 Subject: [PATCH 18/24] Week strip widget: this week's days with event-colour dots, today filled (#436) --- app/src/main/AndroidManifest.xml | 12 ++ .../calendula/widget/WidgetData.kt | 64 ++++++ .../calendula/widget/WidgetKind.kt | 9 + .../calendula/widget/WidgetParts.kt | 14 ++ .../calendula/widget/agenda/AgendaWidget.kt | 10 +- .../widget/config/WidgetConfigScreen.kt | 4 + .../widget/config/WidgetConfigViewModel.kt | 9 + .../calendula/widget/week/WeekStripWidget.kt | 188 ++++++++++++++++++ .../res/drawable/preview_widget_today_bg.xml | 6 + app/src/main/res/drawable/widget_dot.xml | 5 + .../res/layout/widget_preview_week_strip.xml | 24 +++ app/src/main/res/values/strings.xml | 6 + .../main/res/values/widget_preview_styles.xml | 13 ++ .../res/xml/appwidget_info_week_strip.xml | 16 ++ .../calendula/widget/WeekOfTest.kt | 18 ++ 15 files changed, 390 insertions(+), 8 deletions(-) create mode 100644 app/src/main/java/de/jeanlucmakiola/calendula/widget/WidgetParts.kt create mode 100644 app/src/main/java/de/jeanlucmakiola/calendula/widget/week/WeekStripWidget.kt create mode 100644 app/src/main/res/drawable/preview_widget_today_bg.xml create mode 100644 app/src/main/res/drawable/widget_dot.xml create mode 100644 app/src/main/res/layout/widget_preview_week_strip.xml create mode 100644 app/src/main/res/values/widget_preview_styles.xml create mode 100644 app/src/main/res/xml/appwidget_info_week_strip.xml create mode 100644 app/src/test/java/de/jeanlucmakiola/calendula/widget/WeekOfTest.kt diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 292223d..acc935c 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -376,6 +376,18 @@ android:resource="@xml/appwidget_info_date" /> + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 0458ecd..cd18448 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -437,6 +437,12 @@ Calendula month Calendula quick add Calendula date + Calendula week strip + + + %1$s, %2$d event + %1$s, %2$d events + Refresh New event Open Calendula to grant calendar access diff --git a/app/src/main/res/values/widget_preview_styles.xml b/app/src/main/res/values/widget_preview_styles.xml new file mode 100644 index 0000000..29c6ff6 --- /dev/null +++ b/app/src/main/res/values/widget_preview_styles.xml @@ -0,0 +1,13 @@ + + + + + diff --git a/app/src/main/res/xml/appwidget_info_week_strip.xml b/app/src/main/res/xml/appwidget_info_week_strip.xml new file mode 100644 index 0000000..1c17bff --- /dev/null +++ b/app/src/main/res/xml/appwidget_info_week_strip.xml @@ -0,0 +1,16 @@ + + diff --git a/app/src/test/java/de/jeanlucmakiola/calendula/widget/WeekOfTest.kt b/app/src/test/java/de/jeanlucmakiola/calendula/widget/WeekOfTest.kt new file mode 100644 index 0000000..c4c0af4 --- /dev/null +++ b/app/src/test/java/de/jeanlucmakiola/calendula/widget/WeekOfTest.kt @@ -0,0 +1,18 @@ +package de.jeanlucmakiola.calendula.widget + +import com.google.common.truth.Truth.assertThat +import kotlinx.datetime.DayOfWeek +import kotlinx.datetime.LocalDate +import org.junit.jupiter.api.Test + +class WeekOfTest { + + @Test + fun `the week starts on the configured day and holds today`() { + val wednesday = LocalDate(2026, 10, 21) + assertThat(weekOf(wednesday, DayOfWeek.MONDAY)).isEqualTo(LocalDate(2026, 10, 19)..LocalDate(2026, 10, 25)) + assertThat(weekOf(wednesday, DayOfWeek.SUNDAY)).isEqualTo(LocalDate(2026, 10, 18)..LocalDate(2026, 10, 24)) + assertThat(weekOf(wednesday, DayOfWeek.WEDNESDAY).start).isEqualTo(wednesday) + assertThat(weekOf(wednesday, DayOfWeek.THURSDAY).start).isEqualTo(LocalDate(2026, 10, 15)) + } +} From 670cdb226a4a82bd275ed67050aa9a8a4e7129bd Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Fri, 23 Oct 2026 18:27:49 +0200 Subject: [PATCH 19/24] Upcoming widget: today's date over the next events, rows fitted to the height (#437) --- app/src/main/AndroidManifest.xml | 12 ++ .../calendula/widget/WidgetEventRow.kt | 114 +++++++++++ .../calendula/widget/WidgetKind.kt | 9 + .../calendula/widget/WidgetParts.kt | 41 ++++ .../calendula/widget/agenda/AgendaWidget.kt | 26 +-- .../widget/config/WidgetConfigScreen.kt | 2 + .../widget/config/WidgetConfigViewModel.kt | 2 + .../widget/upcoming/UpcomingWidget.kt | 180 ++++++++++++++++++ .../res/drawable/preview_widget_row_bg.xml | 6 + .../res/layout/widget_preview_upcoming.xml | 23 +++ .../widget_preview_colors.xml | 1 + .../values-night/widget_preview_colors.xml | 1 + .../res/values-v31/widget_preview_colors.xml | 1 + app/src/main/res/values/strings.xml | 2 + .../main/res/values/widget_preview_colors.xml | 1 + .../main/res/values/widget_preview_styles.xml | 10 + .../main/res/xml/appwidget_info_upcoming.xml | 16 ++ .../widget/upcoming/UpcomingEventsTest.kt | 51 +++++ 18 files changed, 474 insertions(+), 24 deletions(-) create mode 100644 app/src/main/java/de/jeanlucmakiola/calendula/widget/WidgetEventRow.kt create mode 100644 app/src/main/java/de/jeanlucmakiola/calendula/widget/upcoming/UpcomingWidget.kt create mode 100644 app/src/main/res/drawable/preview_widget_row_bg.xml create mode 100644 app/src/main/res/layout/widget_preview_upcoming.xml create mode 100644 app/src/main/res/xml/appwidget_info_upcoming.xml create mode 100644 app/src/test/java/de/jeanlucmakiola/calendula/widget/upcoming/UpcomingEventsTest.kt diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index acc935c..06eb245 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -388,6 +388,18 @@ android:resource="@xml/appwidget_info_week_strip" /> + + + + + + + + + + + + + + + diff --git a/app/src/main/res/values-night-v31/widget_preview_colors.xml b/app/src/main/res/values-night-v31/widget_preview_colors.xml index 26162c8..323de5f 100644 --- a/app/src/main/res/values-night-v31/widget_preview_colors.xml +++ b/app/src/main/res/values-night-v31/widget_preview_colors.xml @@ -5,4 +5,5 @@ @android:color/system_neutral2_200 @android:color/system_accent1_200 @android:color/system_accent1_800 + @android:color/system_accent2_800 diff --git a/app/src/main/res/values-night/widget_preview_colors.xml b/app/src/main/res/values-night/widget_preview_colors.xml index ca6b3f7..233d427 100644 --- a/app/src/main/res/values-night/widget_preview_colors.xml +++ b/app/src/main/res/values-night/widget_preview_colors.xml @@ -5,4 +5,5 @@ #A8ADB2 #A3CBE2 #003348 + #2D3236 diff --git a/app/src/main/res/values-v31/widget_preview_colors.xml b/app/src/main/res/values-v31/widget_preview_colors.xml index 0eb2ece..902c1e0 100644 --- a/app/src/main/res/values-v31/widget_preview_colors.xml +++ b/app/src/main/res/values-v31/widget_preview_colors.xml @@ -5,4 +5,5 @@ @android:color/system_neutral2_700 @android:color/system_accent1_600 @android:color/system_accent1_0 + @android:color/system_accent2_100 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index cd18448..c1672e4 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -443,6 +443,8 @@ %1$s, %2$d event %1$s, %2$d events + Calendula upcoming + No upcoming events Refresh New event Open Calendula to grant calendar access diff --git a/app/src/main/res/values/widget_preview_colors.xml b/app/src/main/res/values/widget_preview_colors.xml index 98d328b..69ddbb1 100644 --- a/app/src/main/res/values/widget_preview_colors.xml +++ b/app/src/main/res/values/widget_preview_colors.xml @@ -8,4 +8,5 @@ #6E7479 #3B5364 #FFFFFF + #E1E6EB diff --git a/app/src/main/res/values/widget_preview_styles.xml b/app/src/main/res/values/widget_preview_styles.xml index 29c6ff6..d4d081b 100644 --- a/app/src/main/res/values/widget_preview_styles.xml +++ b/app/src/main/res/values/widget_preview_styles.xml @@ -10,4 +10,14 @@ @color/widget_preview_on_surface 12sp + diff --git a/app/src/main/res/xml/appwidget_info_upcoming.xml b/app/src/main/res/xml/appwidget_info_upcoming.xml new file mode 100644 index 0000000..e80cddb --- /dev/null +++ b/app/src/main/res/xml/appwidget_info_upcoming.xml @@ -0,0 +1,16 @@ + + diff --git a/app/src/test/java/de/jeanlucmakiola/calendula/widget/upcoming/UpcomingEventsTest.kt b/app/src/test/java/de/jeanlucmakiola/calendula/widget/upcoming/UpcomingEventsTest.kt new file mode 100644 index 0000000..69dd7d2 --- /dev/null +++ b/app/src/test/java/de/jeanlucmakiola/calendula/widget/upcoming/UpcomingEventsTest.kt @@ -0,0 +1,51 @@ +package de.jeanlucmakiola.calendula.widget.upcoming + +import com.google.common.truth.Truth.assertThat +import de.jeanlucmakiola.calendula.data.prefs.PastEventDisplay +import de.jeanlucmakiola.calendula.domain.EventInstance +import de.jeanlucmakiola.calendula.ui.agenda.AgendaDay +import de.jeanlucmakiola.calendula.ui.agenda.AgendaRange +import de.jeanlucmakiola.calendula.widget.AgendaWidgetOptions +import de.jeanlucmakiola.calendula.widget.DaysWidgetData +import de.jeanlucmakiola.calendula.widget.WidgetSize +import kotlinx.datetime.DayOfWeek +import kotlinx.datetime.LocalDate +import org.junit.jupiter.api.Test +import kotlin.time.Duration.Companion.hours +import kotlin.time.Instant + +class UpcomingEventsTest { + + private val now = Instant.parse("2026-10-21T12:00:00Z") + + private fun event(id: Long, start: Instant, end: Instant) = EventInstance( + instanceId = id, eventId = id, calendarId = 1, title = "e$id", + start = start, end = end, isAllDay = false, color = 0, location = null, + ) + + private fun data(vararg days: AgendaDay) = DaysWidgetData.Ready( + today = LocalDate(2026, 10, 21), + firstDay = LocalDate(2026, 10, 21), + lastDay = LocalDate(2026, 10, 27), + days = days.toList(), + weekStart = DayOfWeek.MONDAY, + is24Hour = true, + soften = true, + options = AgendaWidgetOptions(AgendaRange.Week, WidgetSize.SMALL, true, PastEventDisplay.SHOW), + now = now, + ) + + @Test + fun `ended events drop out and a multi-day one lists once, on its first day`() { + val ended = event(1, now - 3.hours, now - 1.hours) + val running = event(2, now - 1.hours, now + 30.hours) + val later = event(3, now + 2.hours, now + 3.hours) + val today = AgendaDay(LocalDate(2026, 10, 21), listOf(ended, running, later)) + val tomorrow = AgendaDay(LocalDate(2026, 10, 22), listOf(running)) + + val upcoming = upcomingEvents(data(today, tomorrow)) + + assertThat(upcoming.map { it.second.instanceId }).containsExactly(2L, 3L).inOrder() + assertThat(upcoming.first().first).isEqualTo(LocalDate(2026, 10, 21)) + } +} From 075940d29e98911cf2bd3e03a460de6896441c25 Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Fri, 23 Oct 2026 18:29:25 +0200 Subject: [PATCH 20/24] Today list widget: today's events, then tomorrow's first ones once the day is done (#438) --- app/src/main/AndroidManifest.xml | 12 + .../calendula/widget/WidgetKind.kt | 9 + .../widget/config/WidgetConfigScreen.kt | 3 + .../widget/config/WidgetConfigViewModel.kt | 2 + .../calendula/widget/today/TodayWidget.kt | 243 ++++++++++++++++++ .../main/res/layout/widget_preview_today.xml | 31 +++ app/src/main/res/values/strings.xml | 2 + app/src/main/res/xml/appwidget_info_today.xml | 18 ++ .../calendula/widget/today/TodayRowsTest.kt | 57 ++++ 9 files changed, 377 insertions(+) create mode 100644 app/src/main/java/de/jeanlucmakiola/calendula/widget/today/TodayWidget.kt create mode 100644 app/src/main/res/layout/widget_preview_today.xml create mode 100644 app/src/main/res/xml/appwidget_info_today.xml create mode 100644 app/src/test/java/de/jeanlucmakiola/calendula/widget/today/TodayRowsTest.kt diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 06eb245..2907953 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -400,6 +400,18 @@ android:resource="@xml/appwidget_info_upcoming" /> + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index c1672e4..335accf 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -445,6 +445,8 @@ Calendula upcoming No upcoming events + Calendula today + Nothing left today Refresh New event Open Calendula to grant calendar access diff --git a/app/src/main/res/xml/appwidget_info_today.xml b/app/src/main/res/xml/appwidget_info_today.xml new file mode 100644 index 0000000..753674b --- /dev/null +++ b/app/src/main/res/xml/appwidget_info_today.xml @@ -0,0 +1,18 @@ + + diff --git a/app/src/test/java/de/jeanlucmakiola/calendula/widget/today/TodayRowsTest.kt b/app/src/test/java/de/jeanlucmakiola/calendula/widget/today/TodayRowsTest.kt new file mode 100644 index 0000000..a0cb36c --- /dev/null +++ b/app/src/test/java/de/jeanlucmakiola/calendula/widget/today/TodayRowsTest.kt @@ -0,0 +1,57 @@ +package de.jeanlucmakiola.calendula.widget.today + +import com.google.common.truth.Truth.assertThat +import de.jeanlucmakiola.calendula.data.prefs.PastEventDisplay +import de.jeanlucmakiola.calendula.domain.EventInstance +import de.jeanlucmakiola.calendula.ui.agenda.AgendaDay +import de.jeanlucmakiola.calendula.ui.agenda.AgendaRange +import de.jeanlucmakiola.calendula.widget.AgendaWidgetOptions +import de.jeanlucmakiola.calendula.widget.DaysWidgetData +import de.jeanlucmakiola.calendula.widget.WidgetSize +import kotlinx.datetime.DayOfWeek +import kotlinx.datetime.LocalDate +import org.junit.jupiter.api.Test +import kotlin.time.Duration.Companion.hours +import kotlin.time.Instant + +class TodayRowsTest { + + private val now = Instant.parse("2026-10-21T12:00:00Z") + private val today = LocalDate(2026, 10, 21) + private val tomorrow = LocalDate(2026, 10, 22) + + private fun event(id: Long, start: Instant, end: Instant) = EventInstance( + instanceId = id, eventId = id, calendarId = 1, title = "e$id", + start = start, end = end, isAllDay = false, color = 0, location = null, + ) + + private fun data(past: PastEventDisplay, vararg days: AgendaDay) = DaysWidgetData.Ready( + today = today, + firstDay = today, + lastDay = tomorrow, + days = days.toList(), + weekStart = DayOfWeek.MONDAY, + is24Hour = true, + soften = true, + options = AgendaWidgetOptions(AgendaRange.Day, WidgetSize.SMALL, true, past), + now = now, + ) + + private val ended = event(1, now - 3.hours, now - 1.hours) + private val later = event(2, now + 1.hours, now + 2.hours) + private val next = event(3, now + 22.hours, now + 23.hours) + + @Test + fun `an event still to come lists today, finished ones as the past setting says`() { + val days = arrayOf(AgendaDay(today, listOf(ended, later)), AgendaDay(tomorrow, listOf(next))) + assertThat(todayRows(data(PastEventDisplay.DIM, *days))).isEqualTo(TodayRows.Today(listOf(ended, later))) + assertThat(todayRows(data(PastEventDisplay.HIDE, *days))).isEqualTo(TodayRows.Today(listOf(later))) + } + + @Test + fun `once today is over it looks ahead to tomorrow`() { + val over = data(PastEventDisplay.SHOW, AgendaDay(today, listOf(ended)), AgendaDay(tomorrow, listOf(next))) + assertThat(todayRows(over)).isEqualTo(TodayRows.NothingLeft(listOf(next))) + assertThat(todayRows(data(PastEventDisplay.SHOW))).isEqualTo(TodayRows.NothingLeft(emptyList())) + } +} From 343a8904ead99b06e110554a87fb0d71fd060ae5 Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Fri, 23 Oct 2026 18:36:35 +0200 Subject: [PATCH 21/24] Countdown widget: next timed event or a chosen event/series, redrawn when its label changes (#439) --- app/src/main/AndroidManifest.xml | 17 ++ .../data/calendar/CalendarDataSource.kt | 38 ++- .../data/calendar/CalendarRepository.kt | 3 + .../data/calendar/CalendarRepositoryImpl.kt | 5 + .../calendula/ui/common/TimeZonePicker.kt | 8 +- .../calendula/widget/WidgetKind.kt | 9 + .../widget/config/CountdownOptionsSection.kt | 178 ++++++++++++++ .../widget/config/WidgetConfigActivity.kt | 2 + .../widget/config/WidgetConfigScreen.kt | 18 ++ .../widget/config/WidgetConfigViewModel.kt | 55 +++++ .../calendula/widget/countdown/Countdown.kt | 96 ++++++++ .../widget/countdown/CountdownData.kt | 167 +++++++++++++ .../widget/countdown/CountdownWidget.kt | 222 ++++++++++++++++++ .../res/layout/widget_preview_countdown.xml | 27 +++ app/src/main/res/values/strings.xml | 45 ++++ .../main/res/xml/appwidget_info_countdown.xml | 16 ++ .../widget/countdown/CountdownTest.kt | 89 +++++++ 17 files changed, 981 insertions(+), 14 deletions(-) create mode 100644 app/src/main/java/de/jeanlucmakiola/calendula/widget/config/CountdownOptionsSection.kt create mode 100644 app/src/main/java/de/jeanlucmakiola/calendula/widget/countdown/Countdown.kt create mode 100644 app/src/main/java/de/jeanlucmakiola/calendula/widget/countdown/CountdownData.kt create mode 100644 app/src/main/java/de/jeanlucmakiola/calendula/widget/countdown/CountdownWidget.kt create mode 100644 app/src/main/res/layout/widget_preview_countdown.xml create mode 100644 app/src/main/res/xml/appwidget_info_countdown.xml create mode 100644 app/src/test/java/de/jeanlucmakiola/calendula/widget/countdown/CountdownTest.kt diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 2907953..2247f05 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -412,6 +412,23 @@ android:resource="@xml/appwidget_info_today" /> + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 335accf..3072061 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -447,6 +447,51 @@ No upcoming events Calendula today Nothing left today + Calendula countdown + Now + Ending now + Tomorrow + Ends tomorrow + + Until %1$s + + in %d min + in %d min + + + in %d h + in %d h + + + in %d day + in %d days + + + ends in %d min + ends in %d min + + + ends in %d h + ends in %d h + + + ends in %d day + ends in %d days + + Tap to choose an event to count down to + + %1$s is over + Count down to + Next event + Chosen event + The next timed event in this widget\'s calendars, or the one running now. All-day and declined events are skipped. + Event + None chosen yet + Search events + No events in the coming year match. + Couldn\'t load your events. + + %1$s · repeats Refresh New event Open Calendula to grant calendar access diff --git a/app/src/main/res/xml/appwidget_info_countdown.xml b/app/src/main/res/xml/appwidget_info_countdown.xml new file mode 100644 index 0000000..e1a1071 --- /dev/null +++ b/app/src/main/res/xml/appwidget_info_countdown.xml @@ -0,0 +1,16 @@ + + diff --git a/app/src/test/java/de/jeanlucmakiola/calendula/widget/countdown/CountdownTest.kt b/app/src/test/java/de/jeanlucmakiola/calendula/widget/countdown/CountdownTest.kt new file mode 100644 index 0000000..285e9ed --- /dev/null +++ b/app/src/test/java/de/jeanlucmakiola/calendula/widget/countdown/CountdownTest.kt @@ -0,0 +1,89 @@ +package de.jeanlucmakiola.calendula.widget.countdown + +import com.google.common.truth.Truth.assertThat +import de.jeanlucmakiola.calendula.domain.EventInstance +import de.jeanlucmakiola.calendula.domain.EventResponse +import kotlinx.datetime.TimeZone +import org.junit.jupiter.api.Test +import kotlin.time.Duration.Companion.days +import kotlin.time.Duration.Companion.hours +import kotlin.time.Duration.Companion.minutes +import kotlin.time.Duration.Companion.seconds +import kotlin.time.Instant + +class CountdownTest { + + private val zone = TimeZone.UTC + private val now = Instant.parse("2026-10-21T12:00:00Z") + + private fun event( + start: Instant, + end: Instant = start + 1.hours, + id: Long = 1, + allDay: Boolean = false, + response: EventResponse = EventResponse.Going, + ) = EventInstance( + instanceId = id, eventId = id, calendarId = 1, title = "e$id", + start = start, end = end, isAllDay = allDay, color = 0, location = null, response = response, + ) + + @Test + fun `labels step from days to hours to minutes to now`() { + assertThat(countdownLabel(event(now + 30.seconds), now, zone)).isEqualTo(CountdownLabel(false, CountdownUnit.NOW, 0)) + assertThat(countdownLabel(event(now + 24.minutes + 10.seconds), now, zone)).isEqualTo(CountdownLabel(false, CountdownUnit.MINUTES, 25)) + assertThat(countdownLabel(event(now + 2.hours + 40.minutes), now, zone)).isEqualTo(CountdownLabel(false, CountdownUnit.HOURS, 3)) + assertThat(countdownLabel(event(now + 1.hours + 10.minutes), now, zone)).isEqualTo(CountdownLabel(false, CountdownUnit.HOURS, 1)) + // Tomorrow 23:00 is 35 h away but one calendar day. + assertThat(countdownLabel(event(now + 35.hours), now, zone)).isEqualTo(CountdownLabel(false, CountdownUnit.DAYS, 1)) + assertThat(countdownLabel(event(now + 12.days), now, zone)).isEqualTo(CountdownLabel(false, CountdownUnit.DAYS, 12)) + } + + @Test + fun `a running event counts to its end`() { + val running = event(start = now - 20.minutes, end = now + 10.minutes) + assertThat(countdownLabel(running, now, zone)).isEqualTo(CountdownLabel(true, CountdownUnit.MINUTES, 10)) + } + + @Test + fun `the next redraw lands where the label changes`() { + // "in 25 min" turns into "in 24 min" a minute later. + assertThat(nextLabelChange(event(now + 25.minutes), now, zone)).isEqualTo(now + 1.minutes) + // "in 3 h" (2 h 40 min) holds until 2 h 30 min are left. + assertThat(nextLabelChange(event(now + 2.hours + 40.minutes), now, zone)).isEqualTo(now + 10.minutes) + // "in 1 h" holds until the last hour, then counts minutes. + assertThat(nextLabelChange(event(now + 1.hours + 10.minutes), now, zone)).isEqualTo(now + 10.minutes) + // Days change at midnight. + assertThat(nextLabelChange(event(now + 5.days), now, zone)).isEqualTo(Instant.parse("2026-10-22T00:00:00Z")) + } + + @Test + fun `next mode skips all-day, declined and finished events`() { + val allDay = event(now + 1.hours, id = 1, allDay = true) + val declined = event(now + 1.hours, id = 2, response = EventResponse.Declined) + val over = event(now - 2.hours, now - 1.hours, id = 3) + val later = event(now + 3.hours, id = 4) + val running = event(now - 1.hours, now + 2.hours, id = 5) + assertThat(pickNextEvent(listOf(allDay, declined, over, later), now)?.eventId).isEqualTo(4) + // A running event ending before the next one starts comes first. + assertThat(pickNextEvent(listOf(later, running), now)?.eventId).isEqualTo(5) + } + + @Test + fun `a pinned series follows its next occurrence`() { + val past = event(now - 7.days) + val next = event(now + 7.days) + assertThat(pickPinnedOccurrence(listOf(past, next), now, zone)).isEqualTo(next) + assertThat(pickPinnedOccurrence(listOf(past), now, zone)).isNull() + } + + @Test + fun `an all-day event counts in local days west of UTC`() { + val newYork = TimeZone.of("America/New_York") + // 21 Oct, 20:00 in New York; the vacation is all day on the 23rd. + val evening = Instant.parse("2026-10-22T00:00:00Z") + val vacation = event(Instant.parse("2026-10-23T00:00:00Z"), Instant.parse("2026-10-24T00:00:00Z"), allDay = true) + val counted = vacation.counted(newYork) + assertThat(counted.start).isEqualTo(Instant.parse("2026-10-23T04:00:00Z")) + assertThat(countdownLabel(counted, evening, newYork)).isEqualTo(CountdownLabel(false, CountdownUnit.DAYS, 2)) + } +} From c9cc40ff6004387149a39905acfaea722604edf0 Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Fri, 23 Oct 2026 18:51:49 +0200 Subject: [PATCH 22/24] Month and day widget: compact month grid beside the selected day's events (#441) --- app/src/main/AndroidManifest.xml | 12 + .../calendula/widget/WidgetKind.kt | 9 + .../widget/config/WidgetConfigScreen.kt | 9 + .../widget/config/WidgetConfigViewModel.kt | 8 + .../calendula/widget/split/MonthDayWidget.kt | 440 ++++++++++++++++++ .../res/layout/widget_preview_month_day.xml | 48 ++ app/src/main/res/values/strings.xml | 2 + .../main/res/xml/appwidget_info_month_day.xml | 16 + .../widget/split/MonthGridDaysTest.kt | 24 + 9 files changed, 568 insertions(+) create mode 100644 app/src/main/java/de/jeanlucmakiola/calendula/widget/split/MonthDayWidget.kt create mode 100644 app/src/main/res/layout/widget_preview_month_day.xml create mode 100644 app/src/main/res/xml/appwidget_info_month_day.xml create mode 100644 app/src/test/java/de/jeanlucmakiola/calendula/widget/split/MonthGridDaysTest.kt diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 2247f05..71929e5 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -429,6 +429,18 @@ android:name=".widget.countdown.CountdownTickReceiver" android:exported="false" /> + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 3072061..a9f1e0e 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -492,6 +492,8 @@ Couldn\'t load your events. %1$s · repeats + Calendula month and day + No events Refresh New event Open Calendula to grant calendar access diff --git a/app/src/main/res/xml/appwidget_info_month_day.xml b/app/src/main/res/xml/appwidget_info_month_day.xml new file mode 100644 index 0000000..244dbc7 --- /dev/null +++ b/app/src/main/res/xml/appwidget_info_month_day.xml @@ -0,0 +1,16 @@ + + diff --git a/app/src/test/java/de/jeanlucmakiola/calendula/widget/split/MonthGridDaysTest.kt b/app/src/test/java/de/jeanlucmakiola/calendula/widget/split/MonthGridDaysTest.kt new file mode 100644 index 0000000..40578e6 --- /dev/null +++ b/app/src/test/java/de/jeanlucmakiola/calendula/widget/split/MonthGridDaysTest.kt @@ -0,0 +1,24 @@ +package de.jeanlucmakiola.calendula.widget.split + +import com.google.common.truth.Truth.assertThat +import kotlinx.datetime.DayOfWeek +import kotlinx.datetime.LocalDate +import org.junit.jupiter.api.Test + +class MonthGridDaysTest { + + @Test + fun `whole weeks from the week start cover the month and no more`() { + // October 2026 starts on a Thursday and has 31 days. + val october = 2026 * 12 + 9 + val monday = monthGridDays(october, DayOfWeek.MONDAY) + assertThat(monday.first()).isEqualTo(LocalDate(2026, 9, 28)) + assertThat(monday.last()).isEqualTo(LocalDate(2026, 11, 1)) + assertThat(monday).hasSize(35) + + // February 2027 starts on a Monday and fills exactly four weeks. + val february = 2027 * 12 + 1 + assertThat(monthGridDays(february, DayOfWeek.MONDAY)).hasSize(28) + assertThat(monthGridDays(february, DayOfWeek.SUNDAY)).hasSize(35) + } +} From a08ad15c4a362f7397ea345da1a0da00c1b40416 Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Mon, 26 Oct 2026 20:08:11 +0100 Subject: [PATCH 23/24] Month and day widget: clear the fill of previously tapped days (#441) --- .../calendula/widget/split/MonthDayWidget.kt | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/widget/split/MonthDayWidget.kt b/app/src/main/java/de/jeanlucmakiola/calendula/widget/split/MonthDayWidget.kt index e8aa018..9f12100 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/widget/split/MonthDayWidget.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/widget/split/MonthDayWidget.kt @@ -2,6 +2,7 @@ package de.jeanlucmakiola.calendula.widget.split import android.content.Context import androidx.compose.runtime.Composable +import androidx.compose.ui.graphics.Color import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp @@ -45,6 +46,7 @@ import androidx.glance.text.FontWeight import androidx.glance.text.Text import androidx.glance.text.TextAlign import androidx.glance.text.TextStyle +import androidx.glance.unit.ColorProvider import de.jeanlucmakiola.calendula.MainActivity import de.jeanlucmakiola.calendula.R import de.jeanlucmakiola.calendula.data.prefs.PastEventDisplay @@ -354,9 +356,10 @@ private fun DayCell(date: LocalDate, inMonth: Boolean, isToday: Boolean, isSelec inMonth -> GlanceTheme.colors.onSurface else -> GlanceTheme.colors.outline } - val fill = if (isSelected) GlanceModifier.cornerRadius(10.dp).background(GlanceTheme.colors.primary) else GlanceModifier + // Always set: the host reapplies onto reused views, so a dropped background would linger. + val fill = if (isSelected) GlanceTheme.colors.primary else ColorProvider(Color.Transparent) Column( - modifier = fill + modifier = GlanceModifier.cornerRadius(10.dp).background(fill) .width(width) .fillMaxHeight() .clickable(actionRunCallback(actionParametersOf(SelectDayAction.dateKey to date.toString()))), From 3f81bb9ea508861312fcc4f75fcbe1afaa41fc14 Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Mon, 26 Oct 2026 20:08:18 +0100 Subject: [PATCH 24/24] Widget picker: generated previews on Android 15+, XML mock-ups redrawn to match the widgets --- .../jeanlucmakiola/calendula/CalendulaApp.kt | 3 + .../calendula/widget/WidgetPickerPreview.kt | 177 ++++ .../calendula/widget/agenda/AgendaWidget.kt | 8 + .../widget/countdown/CountdownWidget.kt | 8 + .../calendula/widget/date/DateWidget.kt | 8 + .../calendula/widget/month/MonthWidget.kt | 17 +- .../widget/quickadd/QuickAddWidget.kt | 8 + .../calendula/widget/split/MonthDayWidget.kt | 8 + .../calendula/widget/today/TodayWidget.kt | 11 +- .../widget/upcoming/UpcomingWidget.kt | 8 + .../calendula/widget/week/WeekStripWidget.kt | 8 + app/src/main/res/drawable/preview_stripe.xml | 2 +- .../res/drawable/preview_widget_cell_bg.xml | 6 + ...row_bg.xml => preview_widget_event_bg.xml} | 4 +- .../drawable/preview_widget_outside_bg.xml | 6 + .../main/res/layout/widget_preview_agenda.xml | 236 +++-- .../res/layout/widget_preview_countdown.xml | 54 +- .../main/res/layout/widget_preview_date.xml | 9 +- .../main/res/layout/widget_preview_month.xml | 830 ++++++++++++++++-- .../res/layout/widget_preview_month_day.xml | 510 ++++++++++- .../res/layout/widget_preview_quick_add.xml | 3 +- .../main/res/layout/widget_preview_today.xml | 63 +- .../res/layout/widget_preview_upcoming.xml | 90 +- .../res/layout/widget_preview_week_strip.xml | 202 ++++- .../widget_preview_colors.xml | 5 +- .../values-night/widget_preview_colors.xml | 5 +- .../res/values-v31/widget_preview_colors.xml | 5 +- app/src/main/res/values/strings.xml | 3 + .../main/res/values/widget_preview_colors.xml | 19 +- .../main/res/values/widget_preview_styles.xml | 26 +- floret-kit | 2 +- 31 files changed, 2071 insertions(+), 273 deletions(-) create mode 100644 app/src/main/java/de/jeanlucmakiola/calendula/widget/WidgetPickerPreview.kt create mode 100644 app/src/main/res/drawable/preview_widget_cell_bg.xml rename app/src/main/res/drawable/{preview_widget_row_bg.xml => preview_widget_event_bg.xml} (60%) create mode 100644 app/src/main/res/drawable/preview_widget_outside_bg.xml diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/CalendulaApp.kt b/app/src/main/java/de/jeanlucmakiola/calendula/CalendulaApp.kt index ab7e1d7..d7cc846 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/CalendulaApp.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/CalendulaApp.kt @@ -10,6 +10,7 @@ import de.jeanlucmakiola.calendula.data.contacts.SpecialDatesScheduler import de.jeanlucmakiola.calendula.data.contacts.SpecialDatesSyncWorker import de.jeanlucmakiola.calendula.data.reminders.ReminderMaintenanceScheduler import de.jeanlucmakiola.calendula.data.reminders.ReminderMaintenanceWorker +import de.jeanlucmakiola.calendula.widget.publishCalendulaWidgetPreviews import de.jeanlucmakiola.calendula.widget.syncWidgetRollover import de.jeanlucmakiola.calendula.widget.widgetEntryPoint import de.jeanlucmakiola.floret.crash.CrashConfig @@ -58,10 +59,12 @@ class CalendulaApp : Application() { * (#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. + * The picker previews are republished here too, once per update. */ private fun reconcileWidgetRollover() { CoroutineScope(SupervisorJob() + Dispatchers.Default).launch { syncWidgetRollover(this@CalendulaApp) + publishCalendulaWidgetPreviews(this@CalendulaApp) } } diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/widget/WidgetPickerPreview.kt b/app/src/main/java/de/jeanlucmakiola/calendula/widget/WidgetPickerPreview.kt new file mode 100644 index 0000000..b2a4788 --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/calendula/widget/WidgetPickerPreview.kt @@ -0,0 +1,177 @@ +package de.jeanlucmakiola.calendula.widget + +import android.content.Context +import androidx.glance.appwidget.GlanceAppWidget +import androidx.glance.appwidget.SizeMode +import androidx.glance.appwidget.provideContent +import de.jeanlucmakiola.calendula.R +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.groupAgendaDays +import de.jeanlucmakiola.calendula.widget.agenda.AgendaWidgetContent +import de.jeanlucmakiola.calendula.widget.agenda.AgendaWidgetReceiver +import de.jeanlucmakiola.calendula.widget.countdown.CountdownData +import de.jeanlucmakiola.calendula.widget.countdown.CountdownOptions +import de.jeanlucmakiola.calendula.widget.countdown.CountdownWidgetContent +import de.jeanlucmakiola.calendula.widget.countdown.CountdownWidgetReceiver +import de.jeanlucmakiola.calendula.widget.date.DateWidgetContent +import de.jeanlucmakiola.calendula.widget.date.DateWidgetReceiver +import de.jeanlucmakiola.calendula.widget.month.MonthWidgetContent +import de.jeanlucmakiola.calendula.widget.month.MonthWidgetReceiver +import de.jeanlucmakiola.calendula.widget.quickadd.QuickAddWidgetContent +import de.jeanlucmakiola.calendula.widget.quickadd.QuickAddWidgetReceiver +import de.jeanlucmakiola.calendula.widget.split.MonthDayData +import de.jeanlucmakiola.calendula.widget.split.MonthDayWidgetContent +import de.jeanlucmakiola.calendula.widget.split.MonthDayWidgetReceiver +import de.jeanlucmakiola.calendula.widget.today.TodayWidgetContent +import de.jeanlucmakiola.calendula.widget.today.TodayWidgetReceiver +import de.jeanlucmakiola.calendula.widget.upcoming.UpcomingWidgetContent +import de.jeanlucmakiola.calendula.widget.upcoming.UpcomingWidgetReceiver +import de.jeanlucmakiola.calendula.widget.week.WeekStripWidgetContent +import de.jeanlucmakiola.calendula.widget.week.WeekStripWidgetReceiver +import de.jeanlucmakiola.floret.glance.publishWidgetPreviews +import kotlinx.coroutines.flow.first +import kotlinx.datetime.DateTimeUnit +import kotlinx.datetime.LocalDate +import kotlinx.datetime.LocalTime +import kotlinx.datetime.TimeZone +import kotlinx.datetime.atStartOfDayIn +import kotlinx.datetime.atTime +import kotlinx.datetime.plus +import kotlinx.datetime.toInstant +import java.util.Locale +import kotlin.time.Duration.Companion.hours +import kotlin.time.Duration.Companion.minutes + +/** Republishes the generated picker previews (Android 15+) after an app update or a language change. */ +internal suspend fun publishCalendulaWidgetPreviews(context: Context) { + publishWidgetPreviews( + context, + version = "${context.packageManager.getPackageInfo(context.packageName, 0).lastUpdateTime}-${Locale.getDefault().toLanguageTag()}", + receivers = listOf( + AgendaWidgetReceiver::class, + MonthWidgetReceiver::class, + QuickAddWidgetReceiver::class, + DateWidgetReceiver::class, + WeekStripWidgetReceiver::class, + UpcomingWidgetReceiver::class, + TodayWidgetReceiver::class, + CountdownWidgetReceiver::class, + MonthDayWidgetReceiver::class, + ), + ) +} + +/** The size [kind]'s picker preview is laid out at. */ +internal fun previewSizeOf(kind: WidgetKind) = SizeMode.Responsive(setOf(kind.defaultSize)) + +/** [kind]'s picker preview: the real widget, drawn with sample events around today's morning. */ +internal suspend fun GlanceAppWidget.provideSamplePreview(context: Context, kind: WidgetKind) { + val settings = context.widgetEntryPoint().settingsPrefs().widgetSettings.first() + val zone = systemZone() + val today = today(zone) + val now = today.atTime(8, 0).toInstant(zone) + val instances = context.sampleInstances(today, zone) + val weekStart = settings.weekStart.resolveFirstDay(Locale.getDefault()) + val is24Hour = settings.timeFormat.is24Hour(android.text.format.DateFormat.is24HourFormat(context)) + val options = settings.agendaDefaults + val appearance = WidgetAppearance() + val index = monthIndexOf(today) + val month = MonthWidgetSource.Ready(today, weekStart, settings.soften, instances, index..index) + fun days(range: ClosedRange) = DaysWidgetData.Ready( + today = today, + firstDay = range.start, + lastDay = range.endInclusive, + days = groupAgendaDays(range.start, range.endInclusive, instances, zone), + weekStart = weekStart, + is24Hour = is24Hour, + soften = settings.soften, + options = options, + now = now, + ) + val theme = settings.themeMode + provideContent { + when (kind) { + WidgetKind.AGENDA -> AgendaWidgetContent( + AgendaWidgetData.Ready( + today = today, + days = groupAgendaDays(today, today.plus(6, DateTimeUnit.DAY), instances, zone), + is24Hour = is24Hour, + soften = settings.soften, + pastDisplay = options.pastDisplay, + showToday = options.showToday, + textSize = options.textSize, + now = now, + ), + appearance, + theme, + preview = true, + ) + WidgetKind.MONTH -> MonthWidgetContent(month, index, appearance, theme) + WidgetKind.MONTH_DAY -> MonthDayWidgetContent( + MonthDayData.Ready(month, options, is24Hour, now), + index, + selected = null, + appearance = appearance, + appTheme = theme, + preview = true, + ) + WidgetKind.QUICK_ADD -> QuickAddWidgetContent(today, appearance, theme) + WidgetKind.DATE -> DateWidgetContent(today, appearance, theme) + WidgetKind.WEEK_STRIP -> WeekStripWidgetContent(days(weekOf(today, weekStart)), appearance, theme) + WidgetKind.UPCOMING -> UpcomingWidgetContent(days(today..today.plus(6, DateTimeUnit.DAY)), appearance, theme) + WidgetKind.TODAY -> TodayWidgetContent(days(today..today.plus(1, DateTimeUnit.DAY)), appearance, theme, preview = true) + WidgetKind.COUNTDOWN -> CountdownWidgetContent( + CountdownData.Ready( + options = CountdownOptions(), + event = instances.first { !it.isAllDay && it.start > now }, + now = now, + today = today, + is24Hour = is24Hour, + soften = settings.soften, + appWidgetId = null, + ), + appearance, + theme, + ) + } + } +} + +/** A plausible few weeks: a busy today, a trip tomorrow, and a scatter of days with something on. */ +private fun Context.sampleInstances(today: LocalDate, zone: TimeZone): List { + var id = 0L + fun timed(day: Int, at: LocalTime, length: kotlin.time.Duration, title: Int, color: Int): EventInstance { + val start = today.plus(day, DateTimeUnit.DAY).atTime(at).toInstant(zone) + id++ + return EventInstance(id, id, 1, getString(title), start, start + length, false, color, null) + } + fun allDay(day: Int, title: Int, color: Int): EventInstance { + val date = today.plus(day, DateTimeUnit.DAY) + id++ + return EventInstance( + id, id, 1, getString(title), + date.atStartOfDayIn(TimeZone.UTC), date.plus(1, DateTimeUnit.DAY).atStartOfDayIn(TimeZone.UTC), + true, color, null, + ) + } + return listOf( + timed(0, LocalTime(9, 30), 30.minutes, R.string.settings_past_events_sample_morning, Blue), + timed(0, LocalTime(12, 30), 1.hours, R.string.settings_past_events_sample_midday, Green), + timed(0, LocalTime(19, 0), 2.hours, R.string.settings_past_events_sample_evening, Purple), + allDay(1, R.string.widget_preview_sample_trip, Orange), + timed(2, LocalTime(10, 0), 1.hours, R.string.widget_preview_sample_dentist, Red), + timed(-6, LocalTime(9, 30), 30.minutes, R.string.settings_past_events_sample_morning, Blue), + timed(-2, LocalTime(19, 0), 2.hours, R.string.settings_past_events_sample_evening, Purple), + timed(5, LocalTime(19, 0), 2.hours, R.string.settings_past_events_sample_evening, Purple), + timed(8, LocalTime(9, 30), 30.minutes, R.string.settings_past_events_sample_morning, Blue), + timed(12, LocalTime(12, 30), 1.hours, R.string.settings_past_events_sample_midday, Green), + ).sortedBy { it.start } +} + +private const val Blue = 0xFF039BE5.toInt() +private const val Green = 0xFF33B679.toInt() +private const val Purple = 0xFF8E24AA.toInt() +private const val Orange = 0xFFF4511E.toInt() +private const val Red = 0xFFD50000.toInt() 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 554265c..7afaf48 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 @@ -1,5 +1,8 @@ package de.jeanlucmakiola.calendula.widget.agenda +import de.jeanlucmakiola.calendula.widget.WidgetKind +import de.jeanlucmakiola.calendula.widget.previewSizeOf +import de.jeanlucmakiola.calendula.widget.provideSamplePreview import de.jeanlucmakiola.calendula.domain.isDeclined import de.jeanlucmakiola.calendula.widget.glanceDeclinedDecoration import android.content.Context @@ -95,6 +98,11 @@ class AgendaWidget : GlanceAppWidget() { // the RemoteViews past the binder transaction limit. override val sizeMode = SizeMode.Single + override val previewSizeMode = previewSizeOf(WidgetKind.AGENDA) + + override suspend fun providePreview(context: Context, widgetCategory: Int) = + provideSamplePreview(context, WidgetKind.AGENDA) + override suspend fun provideGlance(context: Context, id: GlanceId) { val stamp = refreshStamp(context, id) val widgetData = WidgetData(context.loadAgendaWidget(id), stamp) diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/widget/countdown/CountdownWidget.kt b/app/src/main/java/de/jeanlucmakiola/calendula/widget/countdown/CountdownWidget.kt index 26a63a9..7bca448 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/widget/countdown/CountdownWidget.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/widget/countdown/CountdownWidget.kt @@ -1,5 +1,8 @@ package de.jeanlucmakiola.calendula.widget.countdown +import de.jeanlucmakiola.calendula.widget.WidgetKind +import de.jeanlucmakiola.calendula.widget.previewSizeOf +import de.jeanlucmakiola.calendula.widget.provideSamplePreview import android.appwidget.AppWidgetManager import android.content.Context import android.content.res.Resources @@ -70,6 +73,11 @@ class CountdownWidget : GlanceAppWidget() { override val sizeMode = SizeMode.Single + override val previewSizeMode = previewSizeOf(WidgetKind.COUNTDOWN) + + override suspend fun providePreview(context: Context, widgetCategory: Int) = + provideSamplePreview(context, WidgetKind.COUNTDOWN) + override suspend fun provideGlance(context: Context, id: GlanceId) { val widgetData = WidgetData(context.loadPlacedCountdown(id), refreshStamp(context, id)) provideContent { diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/widget/date/DateWidget.kt b/app/src/main/java/de/jeanlucmakiola/calendula/widget/date/DateWidget.kt index 70eef2e..e75120f 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/widget/date/DateWidget.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/widget/date/DateWidget.kt @@ -1,5 +1,8 @@ package de.jeanlucmakiola.calendula.widget.date +import de.jeanlucmakiola.calendula.widget.WidgetKind +import de.jeanlucmakiola.calendula.widget.previewSizeOf +import de.jeanlucmakiola.calendula.widget.provideSamplePreview import android.content.Context import androidx.compose.runtime.Composable import androidx.compose.ui.unit.dp @@ -59,6 +62,11 @@ class DateWidget : GlanceAppWidget() { override val sizeMode = SizeMode.Exact + override val previewSizeMode = previewSizeOf(WidgetKind.DATE) + + override suspend fun providePreview(context: Context, widgetCategory: Int) = + provideSamplePreview(context, WidgetKind.DATE) + override suspend fun provideGlance(context: Context, id: GlanceId) { val widgetData = WidgetData(context.loadToday(), refreshStamp(context, id)) provideContent { 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 2b49d70..f45af96 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 @@ -1,5 +1,8 @@ package de.jeanlucmakiola.calendula.widget.month +import de.jeanlucmakiola.calendula.widget.WidgetKind +import de.jeanlucmakiola.calendula.widget.previewSizeOf +import de.jeanlucmakiola.calendula.widget.provideSamplePreview import android.content.Context import androidx.compose.runtime.Composable import androidx.compose.runtime.SideEffect @@ -43,7 +46,6 @@ import androidx.glance.layout.fillMaxWidth import androidx.glance.layout.height import androidx.glance.layout.padding import androidx.glance.layout.size -import androidx.glance.layout.width import androidx.glance.state.PreferencesGlanceStateDefinition import de.jeanlucmakiola.floret.glance.LocalWidgetThemeMode import de.jeanlucmakiola.floret.glance.WidgetColorRole @@ -136,6 +138,11 @@ class MonthWidget : GlanceAppWidget() { */ override val sizeMode = SizeMode.Exact + override val previewSizeMode = previewSizeOf(WidgetKind.MONTH) + + override suspend fun providePreview(context: Context, widgetCategory: Int) = + provideSamplePreview(context, WidgetKind.MONTH) + override suspend fun provideGlance(context: Context, id: GlanceId) { val shown = getAppWidgetState(context, id)[MONTH_INDEX_KEY] ?: currentMonthIndex(systemZone()) @@ -239,8 +246,6 @@ private fun MonthWidgetBody(source: MonthWidgetSource, index: Int) { is MonthWidgetSource.Ready -> { val zone = systemZone() val ym = yearMonthOf(index) - // Column width from the live widget size, minus our H padding. - val colW = (LocalSize.current.width - GRID_HPADDING * 2) / 7 val weeks = layoutMonthWeeks(ym, source.weekStart, source.instances, zone) val context = LocalContext.current val fontScale = context.resources.configuration.fontScale @@ -261,7 +266,7 @@ private fun MonthWidgetBody(source: MonthWidgetSource, index: Int) { // each carry their own fill, so a transparent widget stays // readable without one blob over the wallpaper (#292). Column(modifier = GlanceModifier.fillMaxWidth().defaultWeight()) { - WeekdayHeader(weekStart = source.weekStart, colW = colW) + WeekdayHeader(weekStart = source.weekStart) AndroidRemoteViews( remoteViews = monthGridViews(context, layouts, ym.month, source.today, source.soften, colors, mode), modifier = GlanceModifier.fillMaxWidth().defaultWeight(), @@ -315,10 +320,10 @@ private fun MonthHeader(label: String, today: LocalDate) { } @Composable -private fun WeekdayHeader(weekStart: DayOfWeek, colW: Dp) { +private fun WeekdayHeader(weekStart: DayOfWeek) { Row(modifier = GlanceModifier.fillMaxWidth().padding(bottom = 1.dp)) { weekdayNarrowNames(weekStart).forEach { name -> - Box(modifier = GlanceModifier.width(colW).padding(horizontal = 1.dp)) { + Box(modifier = GlanceModifier.defaultWeight().padding(horizontal = 1.dp)) { Text( text = name, style = TextStyle( diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/widget/quickadd/QuickAddWidget.kt b/app/src/main/java/de/jeanlucmakiola/calendula/widget/quickadd/QuickAddWidget.kt index 83535de..97a4642 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/widget/quickadd/QuickAddWidget.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/widget/quickadd/QuickAddWidget.kt @@ -1,5 +1,8 @@ package de.jeanlucmakiola.calendula.widget.quickadd +import de.jeanlucmakiola.calendula.widget.WidgetKind +import de.jeanlucmakiola.calendula.widget.previewSizeOf +import de.jeanlucmakiola.calendula.widget.provideSamplePreview import android.content.Context import androidx.compose.runtime.Composable import androidx.compose.ui.unit.dp @@ -46,6 +49,11 @@ class QuickAddWidget : GlanceAppWidget() { // Exact: the shape fills whatever square the launcher gives it. override val sizeMode = SizeMode.Exact + override val previewSizeMode = previewSizeOf(WidgetKind.QUICK_ADD) + + override suspend fun providePreview(context: Context, widgetCategory: Int) = + provideSamplePreview(context, WidgetKind.QUICK_ADD) + override suspend fun provideGlance(context: Context, id: GlanceId) { val widgetData = WidgetData(context.loadToday(), refreshStamp(context, id)) provideContent { diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/widget/split/MonthDayWidget.kt b/app/src/main/java/de/jeanlucmakiola/calendula/widget/split/MonthDayWidget.kt index 9f12100..b6ef071 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/widget/split/MonthDayWidget.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/widget/split/MonthDayWidget.kt @@ -1,5 +1,8 @@ package de.jeanlucmakiola.calendula.widget.split +import de.jeanlucmakiola.calendula.widget.WidgetKind +import de.jeanlucmakiola.calendula.widget.previewSizeOf +import de.jeanlucmakiola.calendula.widget.provideSamplePreview import android.content.Context import androidx.compose.runtime.Composable import androidx.compose.ui.graphics.Color @@ -146,6 +149,11 @@ class MonthDayWidget : GlanceAppWidget() { override val sizeMode = SizeMode.Exact + override val previewSizeMode = previewSizeOf(WidgetKind.MONTH_DAY) + + override suspend fun providePreview(context: Context, widgetCategory: Int) = + provideSamplePreview(context, WidgetKind.MONTH_DAY) + override suspend fun provideGlance(context: Context, id: GlanceId) { val stamp = refreshStamp(context, id) val shown = context.widgetState(id)[MONTH_INDEX_KEY] ?: monthIndexOf(today(systemZone())) diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/widget/today/TodayWidget.kt b/app/src/main/java/de/jeanlucmakiola/calendula/widget/today/TodayWidget.kt index db9ec26..bd6e757 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/widget/today/TodayWidget.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/widget/today/TodayWidget.kt @@ -1,5 +1,8 @@ package de.jeanlucmakiola.calendula.widget.today +import de.jeanlucmakiola.calendula.widget.WidgetKind +import de.jeanlucmakiola.calendula.widget.previewSizeOf +import de.jeanlucmakiola.calendula.widget.provideSamplePreview import android.content.Context import androidx.compose.runtime.Composable import androidx.compose.ui.unit.dp @@ -87,6 +90,11 @@ class TodayWidget : GlanceAppWidget() { // Exact: whether one or two of tomorrow's events fit follows the height. override val sizeMode = SizeMode.Exact + override val previewSizeMode = previewSizeOf(WidgetKind.TODAY) + + override suspend fun providePreview(context: Context, widgetCategory: Int) = + provideSamplePreview(context, WidgetKind.TODAY) + override suspend fun provideGlance(context: Context, id: GlanceId) { val widgetData = WidgetData(context.loadTodayList(context.widgetLoadContext(id)), refreshStamp(context, id)) provideContent { @@ -165,7 +173,8 @@ private fun TodayList(events: List, data: DaysWidgetData.Ready, m ) } if (preview) { - Column(modifier = GlanceModifier.fillMaxSize()) { repeat(minOf(events.size, 10)) { row(it) } } + val fits = ((LocalSize.current.height - 12.dp + RowGap) / (metrics.rowHeight + RowGap)).toInt().coerceAtLeast(1) + Column(modifier = GlanceModifier.fillMaxSize()) { repeat(minOf(events.size, fits)) { row(it) } } } else { LazyColumn(modifier = GlanceModifier.fillMaxSize()) { items(events.size) { row(it) } } } diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/widget/upcoming/UpcomingWidget.kt b/app/src/main/java/de/jeanlucmakiola/calendula/widget/upcoming/UpcomingWidget.kt index cc04d2c..1f4ddd7 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/widget/upcoming/UpcomingWidget.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/widget/upcoming/UpcomingWidget.kt @@ -1,5 +1,8 @@ package de.jeanlucmakiola.calendula.widget.upcoming +import de.jeanlucmakiola.calendula.widget.WidgetKind +import de.jeanlucmakiola.calendula.widget.previewSizeOf +import de.jeanlucmakiola.calendula.widget.provideSamplePreview import android.content.Context import androidx.compose.runtime.Composable import androidx.compose.ui.unit.Dp @@ -77,6 +80,11 @@ class UpcomingWidget : GlanceAppWidget() { // Exact: how many rows show follows the height. override val sizeMode = SizeMode.Exact + override val previewSizeMode = previewSizeOf(WidgetKind.UPCOMING) + + override suspend fun providePreview(context: Context, widgetCategory: Int) = + provideSamplePreview(context, WidgetKind.UPCOMING) + override suspend fun provideGlance(context: Context, id: GlanceId) { val widgetData = WidgetData(context.loadUpcoming(context.widgetLoadContext(id)), refreshStamp(context, id)) provideContent { diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/widget/week/WeekStripWidget.kt b/app/src/main/java/de/jeanlucmakiola/calendula/widget/week/WeekStripWidget.kt index 1b48e28..eac2150 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/widget/week/WeekStripWidget.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/widget/week/WeekStripWidget.kt @@ -1,5 +1,8 @@ package de.jeanlucmakiola.calendula.widget.week +import de.jeanlucmakiola.calendula.widget.WidgetKind +import de.jeanlucmakiola.calendula.widget.previewSizeOf +import de.jeanlucmakiola.calendula.widget.provideSamplePreview import android.content.Context import androidx.compose.runtime.Composable import androidx.compose.ui.unit.dp @@ -79,6 +82,11 @@ class WeekStripWidget : GlanceAppWidget() { override val sizeMode = SizeMode.Single + override val previewSizeMode = previewSizeOf(WidgetKind.WEEK_STRIP) + + override suspend fun providePreview(context: Context, widgetCategory: Int) = + provideSamplePreview(context, WidgetKind.WEEK_STRIP) + override suspend fun provideGlance(context: Context, id: GlanceId) { val widgetData = WidgetData(context.loadWeek(id), refreshStamp(context, id)) provideContent { diff --git a/app/src/main/res/drawable/preview_stripe.xml b/app/src/main/res/drawable/preview_stripe.xml index 9351a43..f1be56b 100644 --- a/app/src/main/res/drawable/preview_stripe.xml +++ b/app/src/main/res/drawable/preview_stripe.xml @@ -1,6 +1,6 @@ - + diff --git a/app/src/main/res/drawable/preview_widget_cell_bg.xml b/app/src/main/res/drawable/preview_widget_cell_bg.xml new file mode 100644 index 0000000..2d8aea5 --- /dev/null +++ b/app/src/main/res/drawable/preview_widget_cell_bg.xml @@ -0,0 +1,6 @@ + + + + + diff --git a/app/src/main/res/drawable/preview_widget_row_bg.xml b/app/src/main/res/drawable/preview_widget_event_bg.xml similarity index 60% rename from app/src/main/res/drawable/preview_widget_row_bg.xml rename to app/src/main/res/drawable/preview_widget_event_bg.xml index 79ed94e..0660e55 100644 --- a/app/src/main/res/drawable/preview_widget_row_bg.xml +++ b/app/src/main/res/drawable/preview_widget_event_bg.xml @@ -1,6 +1,6 @@ - - + + diff --git a/app/src/main/res/drawable/preview_widget_outside_bg.xml b/app/src/main/res/drawable/preview_widget_outside_bg.xml new file mode 100644 index 0000000..8882d92 --- /dev/null +++ b/app/src/main/res/drawable/preview_widget_outside_bg.xml @@ -0,0 +1,6 @@ + + + + + diff --git a/app/src/main/res/layout/widget_preview_agenda.xml b/app/src/main/res/layout/widget_preview_agenda.xml index 75721fe..aceb98d 100644 --- a/app/src/main/res/layout/widget_preview_agenda.xml +++ b/app/src/main/res/layout/widget_preview_agenda.xml @@ -1,124 +1,186 @@ - + - - - - + android:orientation="vertical" + android:padding="8dp" + tools:ignore="HardcodedText,NestedWeights,DisableBaselineAlignment,SmallSp,TooManyViews,UselessParent"> - + android:gravity="center_vertical" + android:orientation="horizontal"> + + + + + + + + android:layout_marginTop="0dp" + android:background="@drawable/preview_widget_event_bg" + android:backgroundTint="@color/widget_preview_event_blue" + android:orientation="vertical" + android:paddingHorizontal="10dp" + android:paddingVertical="3dp"> + android:ellipsize="end" + android:maxLines="1" + android:text="@string/settings_past_events_sample_morning" + android:textColor="@color/widget_preview_on_event_light" + android:textSize="12sp" + android:textStyle="bold" /> + android:ellipsize="end" + android:maxLines="1" + android:text="9:30 – 10:00" + android:textColor="@color/widget_preview_on_event_light" + android:textSize="11sp" /> + + + + + + + + + - + android:layout_marginTop="6dp" + android:orientation="horizontal"> + + + android:layout_marginTop="0dp" + android:background="@drawable/preview_widget_event_bg" + android:backgroundTint="@color/widget_preview_event_orange" + android:orientation="vertical" + android:paddingHorizontal="10dp" + android:paddingVertical="3dp"> + android:ellipsize="end" + android:maxLines="1" + android:text="@string/widget_preview_sample_trip" + android:textColor="@color/widget_preview_on_event_light" + android:textSize="12sp" + android:textStyle="bold" /> + android:ellipsize="end" + android:maxLines="1" + android:text="@string/event_detail_all_day" + android:textColor="@color/widget_preview_on_event_light" + android:textSize="11sp" /> - - - - - - - diff --git a/app/src/main/res/layout/widget_preview_countdown.xml b/app/src/main/res/layout/widget_preview_countdown.xml index f6d65ef..966dbc3 100644 --- a/app/src/main/res/layout/widget_preview_countdown.xml +++ b/app/src/main/res/layout/widget_preview_countdown.xml @@ -1,27 +1,49 @@ - + + android:orientation="horizontal" + android:padding="12dp" + tools:ignore="HardcodedText,NestedWeights,DisableBaselineAlignment,SmallSp,TooManyViews,UselessParent"> - + - + android:layout_marginStart="10dp" + android:layout_weight="1" + android:orientation="vertical"> + + + + diff --git a/app/src/main/res/layout/widget_preview_date.xml b/app/src/main/res/layout/widget_preview_date.xml index ed0b51b..a5d8409 100644 --- a/app/src/main/res/layout/widget_preview_date.xml +++ b/app/src/main/res/layout/widget_preview_date.xml @@ -1,5 +1,6 @@ - + diff --git a/app/src/main/res/layout/widget_preview_month.xml b/app/src/main/res/layout/widget_preview_month.xml index 90c2d0c..910375a 100644 --- a/app/src/main/res/layout/widget_preview_month.xml +++ b/app/src/main/res/layout/widget_preview_month.xml @@ -1,114 +1,776 @@ - + + android:orientation="vertical" + android:padding="8dp" + tools:ignore="HardcodedText,NestedWeights,DisableBaselineAlignment,SmallSp,TooManyViews,UselessParent"> - - + android:gravity="center_vertical" + android:orientation="horizontal"> - + + + + - - - - - - - - + + + + + + + - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/widget_preview_month_day.xml b/app/src/main/res/layout/widget_preview_month_day.xml index 51cc676..451a6cd 100644 --- a/app/src/main/res/layout/widget_preview_month_day.xml +++ b/app/src/main/res/layout/widget_preview_month_day.xml @@ -1,38 +1,422 @@ - + + android:padding="8dp" + tools:ignore="HardcodedText,NestedWeights,DisableBaselineAlignment,SmallSp,TooManyViews,UselessParent"> - + android:gravity="center_vertical" + android:orientation="horizontal"> + + + + + + - + android:layout_weight="1.1" + android:orientation="vertical"> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - - - + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/widget_preview_quick_add.xml b/app/src/main/res/layout/widget_preview_quick_add.xml index d2c8211..a896785 100644 --- a/app/src/main/res/layout/widget_preview_quick_add.xml +++ b/app/src/main/res/layout/widget_preview_quick_add.xml @@ -1,5 +1,6 @@ - + - + + tools:ignore="HardcodedText,NestedWeights,DisableBaselineAlignment,SmallSp,TooManyViews,UselessParent"> @@ -25,7 +26,59 @@ android:layout_weight="1" android:gravity="center_vertical" android:orientation="vertical"> - - + + + + + + + + diff --git a/app/src/main/res/layout/widget_preview_upcoming.xml b/app/src/main/res/layout/widget_preview_upcoming.xml index 5004888..b714508 100644 --- a/app/src/main/res/layout/widget_preview_upcoming.xml +++ b/app/src/main/res/layout/widget_preview_upcoming.xml @@ -1,23 +1,89 @@ - + + android:padding="8dp" + tools:ignore="HardcodedText,NestedWeights,DisableBaselineAlignment,SmallSp,TooManyViews,UselessParent"> - + android:gravity="center_vertical" + android:orientation="horizontal" + android:paddingHorizontal="4dp"> + + + - - - + + + + + + + + diff --git a/app/src/main/res/layout/widget_preview_week_strip.xml b/app/src/main/res/layout/widget_preview_week_strip.xml index f14b3cb..e2a0717 100644 --- a/app/src/main/res/layout/widget_preview_week_strip.xml +++ b/app/src/main/res/layout/widget_preview_week_strip.xml @@ -1,24 +1,202 @@ - + + tools:ignore="HardcodedText,NestedWeights,DisableBaselineAlignment,SmallSp,TooManyViews,UselessParent"> - - - - - - - + android:orientation="vertical"> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/values-night-v31/widget_preview_colors.xml b/app/src/main/res/values-night-v31/widget_preview_colors.xml index 323de5f..f987341 100644 --- a/app/src/main/res/values-night-v31/widget_preview_colors.xml +++ b/app/src/main/res/values-night-v31/widget_preview_colors.xml @@ -1,9 +1,10 @@ - @android:color/system_neutral1_900 + @android:color/system_accent2_700 + @android:color/system_accent2_900 + @android:color/system_neutral2_600 @android:color/system_neutral1_50 @android:color/system_neutral2_200 @android:color/system_accent1_200 @android:color/system_accent1_800 - @android:color/system_accent2_800 diff --git a/app/src/main/res/values-night/widget_preview_colors.xml b/app/src/main/res/values-night/widget_preview_colors.xml index 233d427..7df62c3 100644 --- a/app/src/main/res/values-night/widget_preview_colors.xml +++ b/app/src/main/res/values-night/widget_preview_colors.xml @@ -1,9 +1,10 @@ - #101316 + #2B3742 + #1C252D + #3A4651 #E1E3E6 #A8ADB2 #A3CBE2 #003348 - #2D3236 diff --git a/app/src/main/res/values-v31/widget_preview_colors.xml b/app/src/main/res/values-v31/widget_preview_colors.xml index 902c1e0..f706cb2 100644 --- a/app/src/main/res/values-v31/widget_preview_colors.xml +++ b/app/src/main/res/values-v31/widget_preview_colors.xml @@ -1,9 +1,10 @@ - @android:color/system_neutral1_50 + @android:color/system_accent2_100 + @android:color/system_accent2_10 + @android:color/system_neutral2_300 @android:color/system_neutral1_900 @android:color/system_neutral2_700 @android:color/system_accent1_600 @android:color/system_accent1_0 - @android:color/system_accent2_100 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index a9f1e0e..1e940ae 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -621,6 +621,9 @@ Team review Lunch with Robin Choir practice + + Weekend trip + Dentist Agenda Agenda range How far ahead the Agenda screen lists events. diff --git a/app/src/main/res/values/widget_preview_colors.xml b/app/src/main/res/values/widget_preview_colors.xml index 69ddbb1..b584476 100644 --- a/app/src/main/res/values/widget_preview_colors.xml +++ b/app/src/main/res/values/widget_preview_colors.xml @@ -1,12 +1,21 @@ - + - #FBFCFE + #D6E4F0 + #EEF3F8 + #C2CCD6 #191C1F #6E7479 #3B5364 #FFFFFF - #E1E6EB + #039BE5 + #33B679 + #8E24AA + #F4511E + #D50000 + #000000 + #FFFFFF diff --git a/app/src/main/res/values/widget_preview_styles.xml b/app/src/main/res/values/widget_preview_styles.xml index d4d081b..0362d0f 100644 --- a/app/src/main/res/values/widget_preview_styles.xml +++ b/app/src/main/res/values/widget_preview_styles.xml @@ -1,23 +1,29 @@ + + - diff --git a/floret-kit b/floret-kit index f2ffaed..6b5ee40 160000 --- a/floret-kit +++ b/floret-kit @@ -1 +1 @@ -Subproject commit f2ffaed9259180051a5564e7ad10589139cce03a +Subproject commit 6b5ee409b3068f2e7a78be7dea80104fed502cd8