Compare commits

...
22 changed files with 802 additions and 904 deletions
+1
View File
@@ -181,6 +181,7 @@ dependencies {
implementation("de.jeanlucmakiola.floret:core-android") implementation("de.jeanlucmakiola.floret:core-android")
implementation("de.jeanlucmakiola.floret:identity") implementation("de.jeanlucmakiola.floret:identity")
implementation("de.jeanlucmakiola.floret:components") implementation("de.jeanlucmakiola.floret:components")
implementation("de.jeanlucmakiola.floret:glance")
implementation(libs.kotlinx.coroutines.core) implementation(libs.kotlinx.coroutines.core)
debugImplementation(libs.androidx.ui.tooling) debugImplementation(libs.androidx.ui.tooling)
@@ -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
}
}
+8 -6
View File
@@ -343,12 +343,13 @@
</receiver> </receiver>
<!-- Keeps both widgets fresh: the calendar provider broadcasts <!-- Keeps both widgets fresh: the calendar provider broadcasts
PROVIDER_CHANGED on any data change (our writes and external sync). PROVIDER_CHANGED on any data change (external sync; the app's own
The day boundary arrives as the app's own ROLLOVER alarm (#228), by writes redraw directly). The day boundary arrives as the app's own
explicit PendingIntent, so it needs no filter here; DATE_CHANGED is ROLLOVER alarm (#228), by explicit PendingIntent, so it needs no
a free extra only, since Android 8+ withholds it from manifest filter here; DATE_CHANGED is a free extra only, since Android 8+
receivers. The four below re-arm that alarm: TIME_SET / withholds it from manifest receivers. TIME_SET / TIMEZONE_CHANGED
TIMEZONE_CHANGED move the boundary, boot / package-replace wipe it. move the boundary, boot / package-replace wipe the alarm, and
LOCALE_CHANGED redraws the labels in the new language.
Exported: the system broadcasts arrive from outside the app. --> Exported: the system broadcasts arrive from outside the app. -->
<receiver <receiver
android:name=".widget.WidgetUpdateReceiver" android:name=".widget.WidgetUpdateReceiver"
@@ -365,6 +366,7 @@
<action android:name="android.intent.action.TIMEZONE_CHANGED" /> <action android:name="android.intent.action.TIMEZONE_CHANGED" />
<action android:name="android.intent.action.BOOT_COMPLETED" /> <action android:name="android.intent.action.BOOT_COMPLETED" />
<action android:name="android.intent.action.MY_PACKAGE_REPLACED" /> <action android:name="android.intent.action.MY_PACKAGE_REPLACED" />
<action android:name="android.intent.action.LOCALE_CHANGED" />
</intent-filter> </intent-filter>
</receiver> </receiver>
@@ -10,7 +10,8 @@ import de.jeanlucmakiola.calendula.data.contacts.SpecialDatesScheduler
import de.jeanlucmakiola.calendula.data.contacts.SpecialDatesSyncWorker import de.jeanlucmakiola.calendula.data.contacts.SpecialDatesSyncWorker
import de.jeanlucmakiola.calendula.data.reminders.ReminderMaintenanceScheduler import de.jeanlucmakiola.calendula.data.reminders.ReminderMaintenanceScheduler
import de.jeanlucmakiola.calendula.data.reminders.ReminderMaintenanceWorker 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.CrashConfig
import de.jeanlucmakiola.floret.crash.CrashReporter import de.jeanlucmakiola.floret.crash.CrashReporter
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
@@ -43,6 +44,8 @@ class CalendulaApp : Application() {
chooseIssueUrl = getString(R.string.report_issue_choose_url), chooseIssueUrl = getString(R.string.report_issue_choose_url),
), ),
) )
// Before anything that writes, so the widgets see every change (#420).
widgetEntryPoint().widgetRefreshCoordinator().start()
reconcileAutoBackup() reconcileAutoBackup()
reconcileSpecialDates() reconcileSpecialDates()
reconcileCalendarVisibility() reconcileCalendarVisibility()
@@ -58,7 +61,7 @@ class CalendulaApp : Application() {
*/ */
private fun reconcileWidgetRollover() { private fun reconcileWidgetRollover() {
CoroutineScope(SupervisorJob() + Dispatchers.Default).launch { CoroutineScope(SupervisorJob() + Dispatchers.Default).launch {
WidgetRolloverScheduler.sync(this@CalendulaApp) syncWidgetRollover(this@CalendulaApp)
} }
} }
@@ -18,6 +18,10 @@ interface CalendarRepository {
/** Re-query every open [calendars] / [instances] flow. */ /** Re-query every open [calendars] / [instances] flow. */
fun refresh() fun refresh()
/** Emits on the app's own writes and visibility changes; not on provider notifications or [refresh]. */
val localChanges: Flow<Unit>
suspend fun eventDetail(eventId: Long): EventDetail suspend fun eventDetail(eventId: Long): EventDetail
/** /**
@@ -78,15 +78,28 @@ class CalendarRepositoryImpl @Inject constructor(
ticks.tryEmit(Unit) ticks.tryEmit(Unit)
} }
private val writes = MutableSharedFlow<Unit>(
replay = 0,
extraBufferCapacity = 1,
onBufferOverflow = BufferOverflow.DROP_OLDEST,
)
/** Run a provider write on [io], then re-query whether or not it threw. */ /** Run a provider write on [io], then re-query whether or not it threw. */
private suspend fun <T> write(block: suspend () -> T): T = withContext(io) { private suspend fun <T> write(block: suspend () -> T): T = withContext(io) {
try { try {
block() block()
} finally { } finally {
refresh() refresh()
writes.tryEmit(Unit)
} }
} }
override val localChanges: Flow<Unit> = merge(
writes,
prefs.pendingDisabledCalendarIds.distinctUntilChanged().drop(1).map {},
prefs.hiddenCalendarIds.distinctUntilChanged().drop(1).map {},
)
/** /**
* Re-query signal for everything filtered by visibility: the provider's own * Re-query signal for everything filtered by visibility: the provider's own
* notifications, plus every change to the pending switch-off set. * notifications, plus every change to the pending switch-off set.
@@ -3,9 +3,6 @@ package de.jeanlucmakiola.calendula.ui.settings
import android.content.Context import android.content.Context
import android.net.Uri import android.net.Uri
import android.os.Build 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.datastore.preferences.core.Preferences
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope 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.FontRole
import de.jeanlucmakiola.calendula.domain.contacts.SpecialDateType import de.jeanlucmakiola.calendula.domain.contacts.SpecialDateType
import de.jeanlucmakiola.calendula.ui.agenda.AgendaRange 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.CalendarView
import de.jeanlucmakiola.calendula.ui.common.TimelineHours import de.jeanlucmakiola.calendula.ui.common.TimelineHours
import de.jeanlucmakiola.calendula.ui.common.TimelineScale import de.jeanlucmakiola.calendula.ui.common.TimelineScale
@@ -50,12 +46,6 @@ import de.jeanlucmakiola.calendula.ui.month.MonthViewStyle
import de.jeanlucmakiola.calendula.ui.theme.AppFontSettings import de.jeanlucmakiola.calendula.ui.theme.AppFontSettings
import de.jeanlucmakiola.calendula.ui.theme.FONT_CUSTOM_TOKEN import de.jeanlucmakiola.calendula.ui.theme.FONT_CUSTOM_TOKEN
import de.jeanlucmakiola.calendula.widget.WidgetSize 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 kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableSharedFlow
@@ -67,8 +57,6 @@ import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import javax.inject.Inject import javax.inject.Inject
@@ -181,10 +169,6 @@ class SettingsViewModel @Inject constructor(
private val _fontImportFailed = MutableSharedFlow<Unit>(extraBufferCapacity = 1) private val _fontImportFailed = MutableSharedFlow<Unit>(extraBufferCapacity = 1)
val fontImportFailed: SharedFlow<Unit> = _fontImportFailed val fontImportFailed: SharedFlow<Unit> = _fontImportFailed
// Serialises widget redraws: concurrent updateAll calls coalesce around a
// stale read and can leave the widget on an intermediate value.
private val widgetRefreshMutex = Mutex()
/** Contact special-dates sub-page (issue #15). */ /** Contact special-dates sub-page (issue #15). */
val specialDatesState: StateFlow<SpecialDatesUiState> = prefs.data.map { stored -> val specialDatesState: StateFlow<SpecialDatesUiState> = prefs.data.map { stored ->
val templates = stored.specialDatesTitleTemplates val templates = stored.specialDatesTitleTemplates
@@ -267,16 +251,7 @@ class SettingsViewModel @Inject constructor(
fun setDynamicColor(enabled: Boolean) = save { setDynamicColor(enabled) } fun setDynamicColor(enabled: Boolean) = save { setDynamicColor(enabled) }
fun setSoftenColors(enabled: Boolean) { fun setSoftenColors(enabled: Boolean) = save { setSoftenCalendarColors(enabled) }
viewModelScope.launch {
prefs.setSoftenCalendarColors(enabled)
// Both widgets paint event colours through the same softener.
widgetRefreshMutex.withLock {
AgendaWidget().updateAll(appContext)
MonthWidget().updateAll(appContext)
}
}
}
fun setFont(role: FontRole, token: String) = save { fun setFont(role: FontRole, token: String) = save {
when (role) { when (role) {
@@ -304,68 +279,18 @@ class SettingsViewModel @Inject constructor(
} }
} }
fun setWeekStart(pref: WeekStartPref) { fun setWeekStart(pref: WeekStartPref) = save { setWeekStart(pref) }
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 setAgendaScreenRange(range: AgendaRange) = save { setAgendaScreenRange(range) } fun setAgendaScreenRange(range: AgendaRange) = save { setAgendaScreenRange(range) }
fun setAgendaWidgetRange(range: AgendaRange) { fun setAgendaWidgetRange(range: AgendaRange) = save { setAgendaWidgetRange(range) }
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 setAgendaShowRangeBar(enabled: Boolean) = save { setAgendaShowRangeBar(enabled) } fun setAgendaShowRangeBar(enabled: Boolean) = save { setAgendaShowRangeBar(enabled) }
/** /** The size the agenda widget draws its text at (#51); the month widget sizes itself (#103). */
* Set the size the agenda widget draws its text at (#51), via the same fun setWidgetSize(size: WidgetSize) = save { setWidgetSize(size) }
* 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)
}
}
}
fun setAgendaShowToday(enabled: Boolean) { fun setAgendaShowToday(enabled: Boolean) = save { setAgendaShowToday(enabled) }
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 setTimeFormat(pref: TimeFormatPref) = save { setTimeFormat(pref) } fun setTimeFormat(pref: TimeFormatPref) = save { setTimeFormat(pref) }
@@ -396,19 +321,7 @@ class SettingsViewModel @Inject constructor(
} }
} }
fun setPastEventDisplay(mode: PastEventDisplay) { fun setPastEventDisplay(mode: PastEventDisplay) = save { setPastEventDisplay(mode) }
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 setDimCompletedEvents(enabled: Boolean) = save { setDimCompletedEvents(enabled) } fun setDimCompletedEvents(enabled: Boolean) = save { setDimCompletedEvents(enabled) }
@@ -4,7 +4,12 @@ import android.Manifest
import android.content.Context import android.content.Context
import android.content.pm.PackageManager import android.content.pm.PackageManager
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
import androidx.glance.GlanceId
import de.jeanlucmakiola.calendula.data.prefs.PastEventDisplay 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.is24Hour
import de.jeanlucmakiola.calendula.data.prefs.resolveFirstDay import de.jeanlucmakiola.calendula.data.prefs.resolveFirstDay
import de.jeanlucmakiola.calendula.domain.EventInstance import de.jeanlucmakiola.calendula.domain.EventInstance
@@ -13,7 +18,11 @@ import de.jeanlucmakiola.calendula.ui.agenda.AgendaRange
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.dayCount
import de.jeanlucmakiola.calendula.ui.agenda.groupAgendaDays 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.first
import kotlinx.coroutines.flow.map
import kotlinx.datetime.DateTimeUnit import kotlinx.datetime.DateTimeUnit
import kotlinx.datetime.DayOfWeek import kotlinx.datetime.DayOfWeek
import kotlinx.datetime.LocalDate import kotlinx.datetime.LocalDate
@@ -25,6 +34,7 @@ import kotlinx.datetime.plus
import kotlinx.datetime.toInstant import kotlinx.datetime.toInstant
import kotlinx.datetime.toLocalDateTime import kotlinx.datetime.toLocalDateTime
import java.util.Locale import java.util.Locale
import java.util.concurrent.ConcurrentHashMap
import kotlin.time.Clock import kotlin.time.Clock
import kotlin.time.Instant import kotlin.time.Instant
@@ -34,7 +44,34 @@ import kotlin.time.Instant
* the whole window, and on slow devices a wide one held the widget on "loading" * the whole window, and on slow devices a wide one held the widget on "loading"
* (#363). * (#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<Boolean, WeekStartPref> get() = soften to weekStart
}
internal val SettingsPrefs.widgetSettings: Flow<WidgetSettings>
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() internal fun systemZone(): TimeZone = TimeZone.currentSystemDefault()
@@ -51,112 +88,63 @@ sealed interface AgendaWidgetData {
data object NeedsPermission : AgendaWidgetData data object NeedsPermission : AgendaWidgetData
data class Ready( data class Ready(
val today: LocalDate, val today: LocalDate,
/** /** Days with events across the configured range, unfiltered; hiding finished ones happens in the composition. */
* 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.
*/
val days: List<AgendaDay>, val days: List<AgendaDay>,
/** Resolved clock convention for event time labels (the time-format pref). */ /** Resolved clock convention for event time labels (the time-format pref). */
val is24Hour: Boolean, val is24Hour: Boolean,
/** Whether event colours are softened to pastels, or shown raw (#36). */ /** Whether event colours are softened to pastels, or shown raw (#36). */
val soften: Boolean, val soften: Boolean,
/** First day of the week, for the calendar-aligned "this week" range. */ val pastDisplay: PastEventDisplay,
val weekStart: DayOfWeek, /** "Always show today" (#35): anchor today at the top even with no events left. */
/** Saved range pref — the fallback when an instance has no Glance state yet. */ val showToday: Boolean,
val savedRange: AgendaRange, val widgetSize: WidgetSize,
/** 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,
/** Snapshot instant the data was read at, for "has this event ended?" tests. */ /** Snapshot instant the data was read at, for "has this event ended?" tests. */
val now: Instant, val now: Instant,
) : AgendaWidgetData ) : AgendaWidgetData
} }
/** /**
* Source data for the month widget: a wide window of instances plus the * Source data for the month widget: a window of instances plus the week-start
* week-start preference and today. The widget computes each displayed month's * preference and today. The widget computes each displayed month's grid from
* grid from this in-memory list (via `layoutMonthWeeks`) as the user pages, * this in-memory list (via `layoutMonthWeeks`) as the user pages, so paging
* so month navigation is pure recomposition — no reload, no flaky widget * inside the loaded window is pure recomposition.
* session restart.
*/ */
sealed interface MonthWidgetSource { sealed interface MonthWidgetSource {
data object NeedsPermission : MonthWidgetSource data object NeedsPermission : MonthWidgetSource
data class Ready( data class Ready(
val today: LocalDate, val today: LocalDate,
val weekStart: DayOfWeek, val weekStart: DayOfWeek,
val soften: Boolean,
val instances: List<EventInstance>, val instances: List<EventInstance>,
/** Month indices (`year * 12 + monthOrdinal`) whose grids [instances] covers. */ /** Month indices (`year * 12 + monthOrdinal`) whose grids [instances] covers. */
val months: IntRange, val months: IntRange,
) : MonthWidgetSource ) : 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 * One-shot read of the upcoming agenda for the widget. Reuses the app's
* [agendaRange] window and [groupAgendaDays] grouping, and the repository's * [agendaRange] window and [groupAgendaDays] grouping, and the repository's
* [first]-emitted snapshot already has hidden calendars filtered out. * [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 if (!hasCalendarPermission()) return AgendaWidgetData.NeedsPermission
val zone = systemZone() val zone = systemZone()
val anchor = today(zone) val anchor = today(zone)
val ep = widgetEntryPoint() val ep = widgetEntryPoint()
val prefs = ep.settingsPrefs() val settings = ep.settingsPrefs().widgetSettings.first()
val savedRange = prefs.agendaWidgetRange.first() val weekStart = settings.weekStart.resolveFirstDay(Locale.getDefault())
val savedPastDisplay = prefs.pastEventDisplay.first() val dayCount = settings.agendaRange.dayCount(anchor, weekStart)
val showToday = prefs.agendaShowToday.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 window = agendaRange(anchor, dayCount - 1, zone) val window = agendaRange(anchor, dayCount - 1, zone)
val windowEnd = anchor.plus(dayCount - 1, DateTimeUnit.DAY) val windowEnd = anchor.plus(dayCount - 1, DateTimeUnit.DAY)
val instances = ep.calendarRepository().instances(window).first() 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( return AgendaWidgetData.Ready(
today = anchor, today = anchor,
days = groupAgendaDays(anchor, windowEnd, instances, zone), days = groupAgendaDays(anchor, windowEnd, instances, zone),
is24Hour = is24Hour, is24Hour = settings.timeFormat.is24Hour(android.text.format.DateFormat.is24HourFormat(this)),
soften = soften, soften = settings.soften,
weekStart = weekStart, pastDisplay = settings.pastDisplay,
savedRange = savedRange, showToday = settings.showToday,
savedPastDisplay = savedPastDisplay, widgetSize = settings.widgetSize,
savedShowToday = showToday,
savedWidgetSize = prefs.widgetSize.first(),
loadedThrough = windowEnd,
now = Clock.System.now(), now = Clock.System.now(),
) )
} }
@@ -165,17 +153,45 @@ 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) 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
/**
* 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<GlanceId, Entry>()
fun get(id: GlanceId, center: Int, stamp: Long): MonthWidgetSource.Ready? =
entries[id]?.takeIf { it.center == center && it.stamp == stamp && it.source.today == today(systemZone()) }
?.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]. */
private suspend fun Context.loadMonthWidgetSource(centerIndex: Int): MonthWidgetSource {
if (!hasCalendarPermission()) return MonthWidgetSource.NeedsPermission if (!hasCalendarPermission()) return MonthWidgetSource.NeedsPermission
val zone = systemZone() 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 ep = widgetEntryPoint()
val weekStart = ep.settingsPrefs().weekStart.first().resolveFirstDay(Locale.getDefault()) val settings = ep.settingsPrefs().widgetSettings.first()
val months = (center - MONTH_WIDGET_PAD_MONTHS)..(center + MONTH_WIDGET_PAD_MONTHS) val months = (centerIndex - MONTH_WIDGET_PAD_MONTHS)..(centerIndex + MONTH_WIDGET_PAD_MONTHS)
// Padded by the grid's spill into the neighbouring months. // Padded by the grid's spill into the neighbouring months.
val from = firstDayOfMonthIndex(months.first) val from = firstDayOfMonthIndex(months.first)
.minus(1, DateTimeUnit.WEEK) .minus(1, DateTimeUnit.WEEK)
@@ -184,11 +200,11 @@ internal suspend fun Context.loadMonthWidgetSource(centerIndex: Int? = null): Mo
.plus(2, DateTimeUnit.WEEK) .plus(2, DateTimeUnit.WEEK)
.atTime(23, 59, 59) .atTime(23, 59, 59)
.toInstant(zone) .toInstant(zone)
val instances = ep.calendarRepository().instances(from..to).first()
return MonthWidgetSource.Ready( return MonthWidgetSource.Ready(
today = anchor, today = today(zone),
weekStart = weekStart, weekStart = settings.weekStart.resolveFirstDay(Locale.getDefault()),
instances = instances, soften = settings.soften,
instances = ep.calendarRepository().instances(from..to).first(),
months = months, months = months,
).also { MonthWidgetCache.data = it } )
} }
@@ -21,6 +21,7 @@ import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
interface WidgetEntryPoint { interface WidgetEntryPoint {
fun calendarRepository(): CalendarRepository fun calendarRepository(): CalendarRepository
fun settingsPrefs(): SettingsPrefs fun settingsPrefs(): SettingsPrefs
fun widgetRefreshCoordinator(): WidgetRefreshCoordinator
} }
internal fun Context.widgetEntryPoint(): WidgetEntryPoint = internal fun Context.widgetEntryPoint(): WidgetEntryPoint =
@@ -0,0 +1,114 @@
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
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.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.launch
import java.util.concurrent.atomic.AtomicBoolean
import java.util.concurrent.atomic.AtomicLong
import javax.inject.Inject
import javax.inject.Singleton
/** Every widget receiver; the set [WidgetRollover] checks for placed instances. */
internal val WIDGET_RECEIVER_CLASSES: List<Class<*>> = 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) {
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. A setting only the agenda widget draws
* with leaves the month widget alone. 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 val started = AtomicBoolean(false)
/** 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 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() }
}
}
}
@@ -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<AlarmManager>() ?: 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
}
@@ -1,39 +1,25 @@
package de.jeanlucmakiola.calendula.widget package de.jeanlucmakiola.calendula.widget
import android.os.Build
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.glance.GlanceTheme
import androidx.glance.material3.ColorProviders
import androidx.glance.text.TextDecoration import androidx.glance.text.TextDecoration
import de.jeanlucmakiola.calendula.ui.theme.CalendulaDarkFallback import de.jeanlucmakiola.calendula.ui.theme.CalendulaDarkFallback
import de.jeanlucmakiola.calendula.ui.theme.CalendulaLightFallback 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). * Glance equivalent of `CalendulaTheme`: Material You on API 31+, the app's
* Reuses the exact same hand-tuned schemes as the in-app theme * hand-tuned fallback schemes below that, following the system's light/dark.
* ([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.
*/ */
@Composable @Composable
fun CalendulaGlanceTheme(content: @Composable () -> Unit) { fun CalendulaGlanceTheme(content: @Composable () -> Unit) {
val colors = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { FloretGlanceTheme(
GlanceTheme.colors lightScheme = CalendulaLightFallback,
} else { darkScheme = CalendulaDarkFallback,
CalendulaGlanceColors dynamicColor = true,
} mode = WidgetThemeMode.SYSTEM,
GlanceTheme(colors = colors, content = content) content = content,
)
} }
/** /**
@@ -1,120 +1,29 @@
package de.jeanlucmakiola.calendula.widget package de.jeanlucmakiola.calendula.widget
import android.content.BroadcastReceiver import android.app.PendingIntent
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
import android.os.SystemClock import de.jeanlucmakiola.floret.glance.FloretWidgetUpdateReceiver
import androidx.glance.appwidget.updateAll import kotlin.time.Duration.Companion.minutes
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
/** /**
* Redraws both home-screen widgets when their data goes stale, and keeps the * Redraws both widgets when their data, day or language goes stale, and keeps
* midnight rollover alarm armed. Triggered by: * the midnight rollover armed. `PROVIDER_CHANGED` covers changes made outside
* - `PROVIDER_CHANGED` from the calendar provider — fires on any data change, * the app; the app's own writes go through [WidgetRefreshCoordinator]. A data
* so it covers both the app's own writes and external sync. * change also re-arms, at most every 15 minutes: the inexact alarm can be
* - [ACTION_ROLLOVER], the app's own alarm from [WidgetRolloverScheduler] — * dropped in a restricted standby bucket, and a calendar change is the one
* the day boundary, so "today" highlighting and the agenda's past-event * signal that keeps arriving. A `PROVIDER_CHANGED` right after one of the
* dimming move on (#228). * app's own changes only echoes it, so it doesn't redraw a second time.
* - `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.
*/ */
class WidgetUpdateReceiver : BroadcastReceiver() { class WidgetUpdateReceiver : FloretWidgetUpdateReceiver() {
override fun onReceive(context: Context, intent: Intent) { override val rolloverAction = ACTION_WIDGET_ROLLOVER
// Exported, so anything can reach it with an explicit intent. Nothing override val widgetReceiverClasses = WIDGET_RECEIVER_CLASSES
// here crosses a trust boundary, but narrowing to the actions we asked override val extraHandledActions = setOf(Intent.ACTION_PROVIDER_CHANGED)
// for keeps a stray broadcast from costing two wide provider reads. override val extraRearmInterval = 15.minutes
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()
}
}
}
companion object { override fun rolloverPendingIntent(context: Context): PendingIntent = widgetRolloverPendingIntent(context)
/** The app's own midnight wake-up; see [WidgetRolloverScheduler]. */
const val ACTION_ROLLOVER = "de.jeanlucmakiola.calendula.widget.ROLLOVER"
/** Both wipe pending alarms, and the host redraws the widgets itself after them. */ override fun skipRedraw(action: String) = action == Intent.ACTION_PROVIDER_CHANGED && isProviderEcho()
private val REARM_ONLY_ACTIONS = setOf(
Intent.ACTION_BOOT_COMPLETED,
Intent.ACTION_MY_PACKAGE_REPLACED,
)
/** override suspend fun redraw(context: Context) = redrawWidgets(context)
* 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)
}
}
} }
@@ -3,18 +3,9 @@ package de.jeanlucmakiola.calendula.widget.agenda
import de.jeanlucmakiola.calendula.domain.isDeclined import de.jeanlucmakiola.calendula.domain.isDeclined
import de.jeanlucmakiola.calendula.widget.glanceDeclinedDecoration import de.jeanlucmakiola.calendula.widget.glanceDeclinedDecoration
import android.content.Context import android.content.Context
import android.content.res.Configuration
import androidx.compose.runtime.Composable 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.graphics.Color
import androidx.compose.ui.unit.dp 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.ColorFilter
import androidx.glance.GlanceId import androidx.glance.GlanceId
import androidx.glance.GlanceModifier import androidx.glance.GlanceModifier
@@ -25,6 +16,7 @@ import androidx.glance.action.ActionParameters
import androidx.glance.action.clickable import androidx.glance.action.clickable
import androidx.glance.appwidget.GlanceAppWidget import androidx.glance.appwidget.GlanceAppWidget
import androidx.glance.appwidget.SizeMode import androidx.glance.appwidget.SizeMode
import androidx.glance.color.ColorProvider
import androidx.glance.appwidget.action.ActionCallback import androidx.glance.appwidget.action.ActionCallback
import androidx.glance.appwidget.action.actionRunCallback import androidx.glance.appwidget.action.actionRunCallback
import androidx.glance.appwidget.action.actionStartActivity import androidx.glance.appwidget.action.actionStartActivity
@@ -33,8 +25,6 @@ import androidx.glance.appwidget.lazy.LazyColumn
import androidx.glance.appwidget.lazy.items import androidx.glance.appwidget.lazy.items
import androidx.glance.appwidget.provideContent import androidx.glance.appwidget.provideContent
import androidx.glance.background import androidx.glance.background
import androidx.glance.currentState
import androidx.glance.appwidget.state.getAppWidgetState
import androidx.glance.state.PreferencesGlanceStateDefinition import androidx.glance.state.PreferencesGlanceStateDefinition
import androidx.glance.layout.Alignment import androidx.glance.layout.Alignment
import androidx.glance.layout.Box import androidx.glance.layout.Box
@@ -53,15 +43,11 @@ import androidx.glance.text.TextStyle
import de.jeanlucmakiola.calendula.MainActivity import de.jeanlucmakiola.calendula.MainActivity
import de.jeanlucmakiola.calendula.R import de.jeanlucmakiola.calendula.R
import de.jeanlucmakiola.calendula.data.prefs.PastEventDisplay 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.EventInstance
import de.jeanlucmakiola.calendula.domain.hasEnded 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.agendaTimeLabel import de.jeanlucmakiola.calendula.ui.agenda.agendaTimeLabel
import de.jeanlucmakiola.calendula.ui.agenda.anchorTodayIfMissing 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.CalendarView
import de.jeanlucmakiola.calendula.ui.common.EventDimAlpha import de.jeanlucmakiola.calendula.ui.common.EventDimAlpha
import de.jeanlucmakiola.calendula.ui.common.formatTimeOfDay import de.jeanlucmakiola.calendula.ui.common.formatTimeOfDay
@@ -72,6 +58,10 @@ import de.jeanlucmakiola.calendula.widget.AgendaWidgetData
import de.jeanlucmakiola.calendula.widget.CalendulaGlanceTheme import de.jeanlucmakiola.calendula.widget.CalendulaGlanceTheme
import de.jeanlucmakiola.calendula.widget.WidgetSize import de.jeanlucmakiola.calendula.widget.WidgetSize
import de.jeanlucmakiola.calendula.widget.loadAgendaWidgetData import de.jeanlucmakiola.calendula.widget.loadAgendaWidgetData
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.systemZone
import de.jeanlucmakiola.calendula.widget.today import de.jeanlucmakiola.calendula.widget.today
import kotlinx.datetime.DateTimeUnit import kotlinx.datetime.DateTimeUnit
@@ -82,40 +72,6 @@ import kotlinx.datetime.toLocalDateTime
import kotlin.time.Instant import kotlin.time.Instant
import java.util.Locale 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 * "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 grouped under day headers (the Google "Schedule" widget model).
@@ -132,28 +88,23 @@ class AgendaWidget : GlanceAppWidget() {
override val sizeMode = SizeMode.Single override val sizeMode = SizeMode.Single
override suspend fun provideGlance(context: Context, id: GlanceId) { override suspend fun provideGlance(context: Context, id: GlanceId) {
val stored = getAppWidgetState<Preferences>(context, id)[AGENDA_RANGE_KEY] val stamp = refreshStamp(context, id)
val data = context.loadAgendaWidgetData(stored?.let { parseAgendaRange(it, AgendaRange.Month) }) val widgetData = WidgetData(context.loadAgendaWidgetData(), stamp)
val dark = (context.resources.configuration.uiMode and
Configuration.UI_MODE_NIGHT_MASK) == Configuration.UI_MODE_NIGHT_YES
provideContent { 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 { CalendulaGlanceTheme {
AgendaWidgetBody(data = data, dark = dark) AgendaWidgetBody(data = data)
} }
} }
} }
} }
/** /** Reloads and redraws the tapped widget (header refresh button). */
* 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]).
*/
class RefreshAgendaAction : ActionCallback { class RefreshAgendaAction : ActionCallback {
override suspend fun onAction(context: Context, glanceId: GlanceId, parameters: ActionParameters) { override suspend fun onAction(context: Context, glanceId: GlanceId, parameters: ActionParameters) {
AgendaWidget().update(context.applicationContext, glanceId) AgendaWidget().updateWithRefresh(context.applicationContext, glanceId)
} }
} }
@@ -175,15 +126,10 @@ private sealed interface AgendaRow {
} }
@Composable @Composable
private fun AgendaWidgetBody(data: AgendaWidgetData, dark: Boolean) { private fun AgendaWidgetBody(data: AgendaWidgetData) {
// Read reactively from per-instance Glance state, falling back to the saved // The stripe is re-resolved against the system font scale so it tracks the
// pref for a freshly placed widget (#103, #51).
// The stripe is then re-resolved against the system font scale so it tracks the
// sp-sized text beside it instead of drifting at large accessibility settings. // sp-sized text beside it instead of drifting at large accessibility settings.
val savedSize = (data as? AgendaWidgetData.Ready)?.savedWidgetSize ?: WidgetSize.SMALL val size = (data as? AgendaWidgetData.Ready)?.widgetSize ?: WidgetSize.SMALL
val size = currentState(AGENDA_SIZE_KEY)
?.let { stored -> WidgetSize.entries.firstOrNull { it.name == stored } }
?: savedSize
val fontScale = androidx.glance.LocalContext.current.resources.configuration.fontScale val fontScale = androidx.glance.LocalContext.current.resources.configuration.fontScale
val metrics = metricsFor(size).scaledForFont(fontScale) val metrics = metricsFor(size).scaledForFont(fontScale)
Column( Column(
@@ -196,27 +142,7 @@ private fun AgendaWidgetBody(data: AgendaWidgetData, dark: Boolean) {
Spacer(GlanceModifier.height(4.dp)) Spacer(GlanceModifier.height(4.dp))
when (data) { when (data) {
AgendaWidgetData.NeedsPermission -> WidgetMessage(R.string.widget_needs_permission, metrics) AgendaWidgetData.NeedsPermission -> WidgetMessage(R.string.widget_needs_permission, metrics)
is AgendaWidgetData.Ready -> { is AgendaWidgetData.Ready -> AgendaRows(data = data, metrics = metrics)
// 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<AgendaWidgetData.Ready>(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)
}
} }
} }
} }
@@ -224,24 +150,15 @@ private fun AgendaWidgetBody(data: AgendaWidgetData, dark: Boolean) {
@Composable @Composable
private fun AgendaRows( private fun AgendaRows(
data: AgendaWidgetData.Ready, data: AgendaWidgetData.Ready,
range: AgendaRange,
dark: Boolean,
metrics: AgendaMetrics, metrics: AgendaMetrics,
) { ) {
val pastDisplay = val pastDisplay = data.pastDisplay
parsePastEventDisplay(currentState(AGENDA_PAST_DISPLAY_KEY), data.savedPastDisplay) // Drop finished events (and any day they leave empty) when the mode is
val showToday = currentState(AGENDA_SHOW_TODAY_STATE_KEY) ?: data.savedShowToday // Hide — so "Upcoming" really is. Then re-anchor today (as an empty
val rangeEnd = data.today.plus( // placeholder day) when the "always show today" pref is on and today has
range.dayCount(data.today, data.weekStart) - 1, // no events left (#35).
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 visibleDays = anchorTodayIfMissing( val visibleDays = anchorTodayIfMissing(
days = data.days days = data.days
.filter { it.date <= rangeEnd }
.let { days -> .let { days ->
if (pastDisplay == PastEventDisplay.HIDE) { if (pastDisplay == PastEventDisplay.HIDE) {
days.mapNotNull { day -> days.mapNotNull { day ->
@@ -253,7 +170,7 @@ private fun AgendaRows(
} }
}, },
today = data.today, today = data.today,
enabled = showToday, enabled = data.showToday,
) )
if (visibleDays.isEmpty()) { if (visibleDays.isEmpty()) {
WidgetMessage(R.string.agenda_empty_title, metrics) WidgetMessage(R.string.agenda_empty_title, metrics)
@@ -280,7 +197,6 @@ private fun AgendaRows(
is AgendaRow.Event -> EventRow( is AgendaRow.Event -> EventRow(
event = row.event, event = row.event,
day = row.date, day = row.date,
dark = dark,
soften = data.soften, soften = data.soften,
is24Hour = data.is24Hour, is24Hour = data.is24Hour,
dimmed = pastDisplay == PastEventDisplay.DIM && dimmed = pastDisplay == PastEventDisplay.DIM &&
@@ -412,7 +328,6 @@ private fun PlaceholderRow(date: LocalDate, metrics: AgendaMetrics) {
private fun EventRow( private fun EventRow(
event: EventInstance, event: EventInstance,
day: LocalDate, day: LocalDate,
dark: Boolean,
soften: Boolean, soften: Boolean,
is24Hour: Boolean, is24Hour: Boolean,
dimmed: Boolean, dimmed: Boolean,
@@ -422,9 +337,10 @@ private fun EventRow(
val title = event.title.ifBlank { context.getString(R.string.event_untitled) } 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 // 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. // 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 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 val titleColor = if (dimmed) GlanceTheme.colors.onSurfaceVariant else GlanceTheme.colors.onSurface
Row( Row(
modifier = GlanceModifier modifier = GlanceModifier
@@ -1,10 +1,12 @@
package de.jeanlucmakiola.calendula.widget.agenda package de.jeanlucmakiola.calendula.widget.agenda
import android.annotation.SuppressLint
import android.appwidget.AppWidgetManager import android.appwidget.AppWidgetManager
import android.content.Context import android.content.Context
import androidx.glance.appwidget.GlanceAppWidget import androidx.glance.appwidget.GlanceAppWidget
import androidx.glance.appwidget.GlanceAppWidgetReceiver import androidx.glance.appwidget.GlanceAppWidgetReceiver
import de.jeanlucmakiola.calendula.widget.WidgetRolloverScheduler 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 * Host-facing receiver for the agenda widget. Declared in the manifest with the
@@ -17,28 +19,30 @@ class AgendaWidgetReceiver : GlanceAppWidgetReceiver() {
/** First agenda widget placed — start rolling "today" over at midnight (#228). */ /** First agenda widget placed — start rolling "today" over at midnight (#228). */
override fun onEnabled(context: Context) { override fun onEnabled(context: Context) {
super.onEnabled(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. * no month widget is left either.
*/ */
override fun onDisabled(context: Context) { override fun onDisabled(context: Context) {
super.onDisabled(context) super.onDisabled(context)
WidgetRolloverScheduler.sync(context) syncWidgetRollover(context)
} }
/** /**
* Self-heal on the system's own `updatePeriodMillis` wake-up — see * Self-heal on the system's own `updatePeriodMillis` wake-up — see
* [de.jeanlucmakiola.calendula.widget.month.MonthWidgetReceiver.onUpdate]. * [de.jeanlucmakiola.calendula.widget.month.MonthWidgetReceiver.onUpdate].
*/ */
@SuppressLint("MissingSuperCall")
override fun onUpdate( override fun onUpdate(
context: Context, context: Context,
appWidgetManager: AppWidgetManager, appWidgetManager: AppWidgetManager,
appWidgetIds: IntArray, appWidgetIds: IntArray,
) { ) {
super.onUpdate(context, appWidgetManager, appWidgetIds) // In place of super, which only recomposes and would take the goAsync handle.
WidgetRolloverScheduler.sync(context) reloadOnUpdate(context, appWidgetIds)
syncWidgetRollover(context)
} }
} }
@@ -0,0 +1,144 @@
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
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: 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<T : Any>(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 <T : Any> mergeGaps(columns: Int, cellAt: (Int) -> GridCell<T>?): List<GridCell<T>> {
val cells = mutableListOf<GridCell<T>>()
var gap = 0
var col = 0
while (col < columns) {
val cell = cellAt(col)
if (cell == null) {
gap += 1
col += 1
continue
}
if (gap > 0) cells += GridCell(null, gap)
gap = 0
cells += cell
col += cell.cols
}
if (gap > 0) cells += GridCell(null, gap)
return cells
}
/** 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<DaySeating> = week.days.indices.map { week.seating(it, laneCap) }
/** 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
/** Each drawn lane as bars and merged gaps. */
val lanes: List<List<GridCell<EventInstance>>> = 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)
}
}
/** The "+N" row as counts and merged gaps; empty when everything fits. */
val overflow: List<GridCell<Int>> = 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
}
}
/**
* 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 layoutWeeks(
weeks: List<MonthWeek>,
today: LocalDate,
ids: ViewIds = ViewIds(),
budget: Int = VIEW_BUDGET - ids.fixed,
): List<WeekLayout> {
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<Int, WeekLayout, Int>? = 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
}
@@ -3,11 +3,10 @@ package de.jeanlucmakiola.calendula.widget.month
import de.jeanlucmakiola.calendula.domain.isDeclined import de.jeanlucmakiola.calendula.domain.isDeclined
import de.jeanlucmakiola.calendula.widget.glanceDeclinedDecoration import de.jeanlucmakiola.calendula.widget.glanceDeclinedDecoration
import android.content.Context import android.content.Context
import android.content.res.Configuration
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.SideEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
@@ -27,6 +26,7 @@ import androidx.glance.action.actionParametersOf
import androidx.glance.action.clickable import androidx.glance.action.clickable
import androidx.glance.appwidget.GlanceAppWidget import androidx.glance.appwidget.GlanceAppWidget
import androidx.glance.appwidget.SizeMode import androidx.glance.appwidget.SizeMode
import androidx.glance.color.ColorProvider
import androidx.glance.appwidget.action.ActionCallback import androidx.glance.appwidget.action.ActionCallback
import androidx.glance.appwidget.action.actionRunCallback import androidx.glance.appwidget.action.actionRunCallback
import androidx.glance.appwidget.action.actionStartActivity import androidx.glance.appwidget.action.actionStartActivity
@@ -66,11 +66,14 @@ import de.jeanlucmakiola.calendula.ui.common.eventInk
import de.jeanlucmakiola.calendula.widget.CalendulaGlanceTheme import de.jeanlucmakiola.calendula.widget.CalendulaGlanceTheme
import de.jeanlucmakiola.calendula.widget.MonthWidgetSource import de.jeanlucmakiola.calendula.widget.MonthWidgetSource
import de.jeanlucmakiola.calendula.widget.loadMonthWidgetSource 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.refreshStamp
import de.jeanlucmakiola.floret.glance.updateWithRefresh
import de.jeanlucmakiola.calendula.widget.monthIndexOf import de.jeanlucmakiola.calendula.widget.monthIndexOf
import de.jeanlucmakiola.calendula.widget.systemZone import de.jeanlucmakiola.calendula.widget.systemZone
import de.jeanlucmakiola.calendula.widget.today import de.jeanlucmakiola.calendula.widget.today
import de.jeanlucmakiola.calendula.widget.widgetEntryPoint
import kotlinx.coroutines.flow.first
import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.Dp
import kotlinx.datetime.DayOfWeek import kotlinx.datetime.DayOfWeek
import kotlinx.datetime.LocalDate import kotlinx.datetime.LocalDate
@@ -83,8 +86,6 @@ import java.util.Locale
/** Per-widget state: the displayed month as `year * 12 + monthOrdinal`. */ /** Per-widget state: the displayed month as `year * 12 + monthOrdinal`. */
private val MONTH_INDEX_KEY = intPreferencesKey("month_index") 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 LANE_HEIGHT = 14.dp
private val DAY_NUMBER_HEIGHT = 18.dp private val DAY_NUMBER_HEIGHT = 18.dp
private val GRID_HPADDING = 8.dp private val GRID_HPADDING = 8.dp
@@ -122,22 +123,38 @@ class MonthWidget : GlanceAppWidget() {
* Exact rather than [SizeMode.Single] or [SizeMode.Responsive], both of which * Exact rather than [SizeMode.Single] or [SizeMode.Responsive], both of which
* return early from `GlanceAppWidget.resize` — under those the grid is never * return early from `GlanceAppWidget.resize` — under those the grid is never
* recomposed for a new size and a resized widget clips instead of reflowing. * 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 * Exact costs one serialised copy per host size, which is why a rendering is
* a rendering is ~190 views rather than ~740. * held to [VIEW_BUDGET] view ids, folding lanes into "+N" on a busy month.
*/ */
override val sizeMode = SizeMode.Exact override val sizeMode = SizeMode.Exact
override suspend fun provideGlance(context: Context, id: GlanceId) { override suspend fun provideGlance(context: Context, id: GlanceId) {
val shown = getAppWidgetState<Preferences>(context, id)[MONTH_INDEX_KEY] val shown = getAppWidgetState<Preferences>(context, id)[MONTH_INDEX_KEY]
val source = context.loadMonthWidgetSource(shown) ?: currentMonthIndex(systemZone())
val dark = (context.resources.configuration.uiMode and val stamp = refreshStamp(context, id)
Configuration.UI_MODE_NIGHT_MASK) == Configuration.UI_MODE_NIGHT_YES val initialCenter = monthWindowCenter(shown)
// Read fresh (not through the cached source) so toggling the softener // Shared by every size Exact composes, so each loads nothing of its own.
// redraws with the new choice; it's one cheap DataStore read. val widgetData = WidgetData(context.loadMonthWidgetSource(id, initialCenter, stamp), initialCenter, stamp)
val soften = context.widgetEntryPoint().settingsPrefs().softenCalendarColors.first()
provideContent { 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)
}
// 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 { CalendulaGlanceTheme {
MonthWidgetBody(source = source, dark = dark, soften = soften) MonthWidgetBody(source = source, index = shown)
} }
} }
} }
@@ -179,16 +196,16 @@ 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 { class ResetMonthAction : ActionCallback {
override suspend fun onAction(context: Context, glanceId: GlanceId, parameters: ActionParameters) { override suspend fun onAction(context: Context, glanceId: GlanceId, parameters: ActionParameters) {
updateAppWidgetState(context, glanceId) { prefs -> prefs.remove(MONTH_INDEX_KEY) } updateAppWidgetState(context, glanceId) { prefs -> prefs.remove(MONTH_INDEX_KEY) }
MonthWidget().update(context.applicationContext, glanceId) MonthWidget().updateWithRefresh(context.applicationContext, glanceId)
} }
} }
@Composable @Composable
private fun MonthWidgetBody(source: MonthWidgetSource, dark: Boolean, soften: Boolean) { private fun MonthWidgetBody(source: MonthWidgetSource, index: Int) {
Column( Column(
modifier = GlanceModifier modifier = GlanceModifier
.fillMaxSize() .fillMaxSize()
@@ -202,32 +219,20 @@ private fun MonthWidgetBody(source: MonthWidgetSource, dark: Boolean, soften: Bo
} }
is MonthWidgetSource.Ready -> { is MonthWidgetSource.Ready -> {
val zone = systemZone() val zone = systemZone()
val index = currentState(MONTH_INDEX_KEY) ?: currentMonthIndex(zone)
val ym = yearMonthOf(index) 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<MonthWidgetSource.Ready>(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. // Column width from the live widget size, minus our H padding.
val colW = (LocalSize.current.width - GRID_HPADDING * 2) / 7 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)) Spacer(GlanceModifier.height(2.dp))
WeekdayHeader(weekStart = loaded.weekStart, colW = colW) WeekdayHeader(weekStart = source.weekStart, colW = colW)
weeks.forEach { week -> layoutWeeks(weeks, source.today).forEach { layout ->
WeekRow( WeekRow(
week = week, layout = layout,
currentMonth = ym.month, currentMonth = ym.month,
today = loaded.today, today = source.today,
dark = dark, soften = source.soften,
soften = soften,
colW = colW, colW = colW,
modifier = GlanceModifier.defaultWeight(), modifier = GlanceModifier.defaultWeight(),
) )
@@ -333,22 +338,21 @@ private fun weekdayNarrowNames(weekStart: DayOfWeek): List<String> {
@Composable @Composable
private fun WeekRow( private fun WeekRow(
week: MonthWeek, layout: WeekLayout,
currentMonth: Month, currentMonth: Month,
today: LocalDate, today: LocalDate,
dark: Boolean,
soften: Boolean, soften: Boolean,
colW: Dp, colW: Dp,
modifier: GlanceModifier, modifier: GlanceModifier,
) { ) {
val context = LocalContext.current val context = LocalContext.current
val hidden = week.hiddenPerDay() val week = layout.week
Box(modifier = modifier.fillMaxWidth()) { Box(modifier = modifier.fillMaxWidth()) {
// Day-open targets, one full-height strip per column, sitting *under* the // 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 // 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 // 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 // above is not clickable, event bars included, so touches fall through to
// event bar opts out to open its own detail. // this layer and a day opens wherever it is tapped (#386).
Row(modifier = GlanceModifier.fillMaxSize()) { Row(modifier = GlanceModifier.fillMaxSize()) {
week.days.forEach { date -> week.days.forEach { date ->
Box( Box(
@@ -379,42 +383,21 @@ private fun WeekRow(
// One lane row per event row. A multi-day span is a single Box spanning // 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. // 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. // Lanes past the last one this week fills emit nothing at all.
repeat(week.usedLanes()) { lane -> // The grid's view budget caps the lanes per week (#372); what doesn't
LaneRow(week = week, lane = lane, dark = dark, 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)) Spacer(GlanceModifier.height(1.dp))
} }
if (hidden.any { it > 0 }) OverflowRow(hidden = hidden, colW = colW) if (layout.overflow.isNotEmpty()) OverflowRow(cells = layout.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<Int> = 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 * 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. * 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 * The only tap target in a day column, so a tap anywhere on a day opens it.
* on a day opens it; only an event bar on top opts out to open its own detail.
*/ */
private fun openDayAction(context: Context, date: LocalDate) = private fun openDayAction(context: Context, date: LocalDate) =
actionStartActivity(MainActivity.openDateIntent(context, date, CalendarView.Month)) actionStartActivity(MainActivity.openDateIntent(context, date, CalendarView.Month))
@@ -453,56 +436,16 @@ 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<LaneCell> {
val cells = mutableListOf<LaneCell>()
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 @Composable
private fun LaneRow(week: MonthWeek, lane: Int, dark: Boolean, soften: Boolean, colW: Dp) { private fun LaneRow(cells: List<GridCell<EventInstance>>, soften: Boolean, colW: Dp) {
Row(modifier = GlanceModifier.fillMaxWidth()) { Row(modifier = GlanceModifier.fillMaxWidth()) {
week.laneCells(lane).forEach { cell -> cells.forEach { cell ->
if (cell.event == null) { val event = cell.item
if (event == null) {
Spacer(GlanceModifier.width(colW * cell.cols).height(LANE_HEIGHT)) Spacer(GlanceModifier.width(colW * cell.cols).height(LANE_HEIGHT))
} else { } else {
SpanBar( SpanBar(
event = cell.event, event = event,
dark = dark,
soften = soften, soften = soften,
width = colW * cell.cols, width = colW * cell.cols,
) )
@@ -513,39 +456,28 @@ private fun LaneRow(week: MonthWeek, lane: Int, dark: Boolean, soften: Boolean,
/** A single connected, rounded event bar [width] wide with its clipped title. */ /** A single connected, rounded event bar [width] wide with its clipped title. */
@Composable @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 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( Box(
modifier = GlanceModifier modifier = GlanceModifier
.width(width) .width(width)
.height(LANE_HEIGHT) .height(LANE_HEIGHT)
.padding(horizontal = 1.dp) .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,
),
),
),
) { ) {
Box( Box(
modifier = GlanceModifier modifier = GlanceModifier
.fillMaxSize() .fillMaxSize()
.cornerRadius(4.dp) .cornerRadius(4.dp)
.background(fill), .background(ColorProvider(day = dayFill, night = nightFill)),
contentAlignment = Alignment.CenterStart, contentAlignment = Alignment.CenterStart,
) { ) {
Text( Text(
text = event.title.ifBlank { context.getString(R.string.event_untitled) }, text = event.title.ifBlank { context.getString(R.string.event_untitled) },
maxLines = 1, maxLines = 1,
style = TextStyle( style = TextStyle(
color = ColorProvider(eventInk(fill)), color = ColorProvider(day = eventInk(dayFill), night = eventInk(nightFill)),
fontSize = 9.sp, fontSize = 9.sp,
textDecoration = glanceDeclinedDecoration(event.isDeclined), textDecoration = glanceDeclinedDecoration(event.isDeclined),
), ),
@@ -557,40 +489,23 @@ private fun SpanBar(event: EventInstance, dark: Boolean, soften: Boolean, width:
/** The "+N" row, drawn only for weeks that hide something, gaps merged as in a lane. */ /** The "+N" row, drawn only for weeks that hide something, gaps merged as in a lane. */
@Composable @Composable
private fun OverflowRow(hidden: List<Int>, colW: Dp) { private fun OverflowRow(cells: List<GridCell<Int>>, colW: Dp) {
Row(modifier = GlanceModifier.fillMaxWidth()) { Row(modifier = GlanceModifier.fillMaxWidth()) {
var gap = 0 cells.forEach { cell ->
hidden.forEach { count -> if (cell.item == null) {
if (count == 0) { Spacer(GlanceModifier.width(colW * cell.cols).height(LANE_HEIGHT))
gap += 1 } else {
return@forEach Text(
text = "+${cell.item}",
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 @Composable
private fun PermissionMessage() { private fun PermissionMessage() {
val context = LocalContext.current val context = LocalContext.current
@@ -1,10 +1,12 @@
package de.jeanlucmakiola.calendula.widget.month package de.jeanlucmakiola.calendula.widget.month
import android.annotation.SuppressLint
import android.appwidget.AppWidgetManager import android.appwidget.AppWidgetManager
import android.content.Context import android.content.Context
import androidx.glance.appwidget.GlanceAppWidget import androidx.glance.appwidget.GlanceAppWidget
import androidx.glance.appwidget.GlanceAppWidgetReceiver import androidx.glance.appwidget.GlanceAppWidgetReceiver
import de.jeanlucmakiola.calendula.widget.WidgetRolloverScheduler 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 * Host-facing receiver for the month widget. Declared in the manifest with the
@@ -16,16 +18,16 @@ class MonthWidgetReceiver : GlanceAppWidgetReceiver() {
/** First month widget placed — start rolling "today" over at midnight (#228). */ /** First month widget placed — start rolling "today" over at midnight (#228). */
override fun onEnabled(context: Context) { override fun onEnabled(context: Context) {
super.onEnabled(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. * no agenda widget is left either.
*/ */
override fun onDisabled(context: Context) { override fun onDisabled(context: Context) {
super.onDisabled(context) super.onDisabled(context)
WidgetRolloverScheduler.sync(context) syncWidgetRollover(context)
} }
/** /**
@@ -35,12 +37,14 @@ class MonthWidgetReceiver : GlanceAppWidgetReceiver() {
* freeze) is repaired here rather than on the next app open. Re-arming * freeze) is repaired here rather than on the next app open. Re-arming
* closer to midnight also narrows the inexact delivery window. * closer to midnight also narrows the inexact delivery window.
*/ */
@SuppressLint("MissingSuperCall")
override fun onUpdate( override fun onUpdate(
context: Context, context: Context,
appWidgetManager: AppWidgetManager, appWidgetManager: AppWidgetManager,
appWidgetIds: IntArray, appWidgetIds: IntArray,
) { ) {
super.onUpdate(context, appWidgetManager, appWidgetIds) // In place of super, which only recomposes and would take the goAsync handle.
WidgetRolloverScheduler.sync(context) reloadOnUpdate(context, appWidgetIds)
syncWidgetRollover(context)
} }
} }
@@ -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 - MONTH_WIDGET_PAD_MONTHS)
assertThat(index + 2).isAtMost(center + MONTH_WIDGET_PAD_MONTHS)
}
}
@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))
}
}
@@ -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,
)
}
}
@@ -0,0 +1,109 @@
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
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)
/** 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<EventInstance>): List<MonthWeek> =
layoutMonthWeeks(jul26, DayOfWeek.MONDAY, events, zone)
/** July 2026 with [perDay] timed events on every day of the grid. */
private fun busyWeeks(perDay: Int): List<MonthWeek> {
val first = LocalDate(2026, 6, 29)
var id = 0L
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<WeekLayout>.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()
}
}
@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)
}
@Test
fun `folded events are counted in the overflow`() {
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 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 `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()
}
}