diff --git a/app/build.gradle.kts b/app/build.gradle.kts
index 16ea6ab..27c551c 100644
--- a/app/build.gradle.kts
+++ b/app/build.gradle.kts
@@ -181,6 +181,7 @@ dependencies {
implementation("de.jeanlucmakiola.floret:core-android")
implementation("de.jeanlucmakiola.floret:identity")
implementation("de.jeanlucmakiola.floret:components")
+ implementation("de.jeanlucmakiola.floret:glance")
implementation(libs.kotlinx.coroutines.core)
debugImplementation(libs.androidx.ui.tooling)
diff --git a/app/src/androidTest/java/de/jeanlucmakiola/calendula/widget/month/MonthWidgetPayloadTest.kt b/app/src/androidTest/java/de/jeanlucmakiola/calendula/widget/month/MonthWidgetPayloadTest.kt
new file mode 100644
index 0000000..5b02619
--- /dev/null
+++ b/app/src/androidTest/java/de/jeanlucmakiola/calendula/widget/month/MonthWidgetPayloadTest.kt
@@ -0,0 +1,140 @@
+package de.jeanlucmakiola.calendula.widget.month
+
+import android.Manifest
+import android.content.ContentUris
+import android.content.ContentValues
+import android.net.Uri
+import android.os.Parcel
+import android.provider.CalendarContract
+import android.util.Log
+import android.view.View
+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 de.jeanlucmakiola.calendula.R
+import java.time.LocalDate
+import java.time.ZoneId
+
+/**
+ * Renders the month widget for a packed month through Glance's own translator
+ * (#372) and checks what one size costs on the wire: the host's binder buffer is
+ * 1 MB for every widget update at once, and Exact sends one copy per size (#214).
+ */
+@OptIn(ExperimentalGlanceApi::class)
+@RunWith(AndroidJUnit4::class)
+class MonthWidgetPayloadTest {
+
+ @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 aPackedMonthRendersWithinItsPayload() = runBlocking {
+ for (size in listOf(DpSize(250.dp, 250.dp), DpSize(320.dp, 360.dp), DpSize(420.dp, 520.dp), DpSize(600.dp, 1000.dp))) {
+ val views = MonthWidget().compose(context, size = size)
+ val bytes = Parcel.obtain().let { parcel ->
+ try {
+ views.writeToParcel(parcel, 0)
+ parcel.dataSize()
+ } finally {
+ parcel.recycle()
+ }
+ }
+ var dayNumbers = 0
+ var bars = 0
+ instrumentation.runOnMainSync {
+ val root = views.apply(context, FrameLayout(context))
+ fun walk(view: View) {
+ if (view.id in DAY_IDS && view is TextView && view.text.isNotEmpty()) dayNumbers += 1
+ if (view.id == R.id.bar_title) bars += 1
+ if (view is ViewGroup) for (i in 0 until view.childCount) walk(view.getChildAt(i))
+ }
+ walk(root)
+ }
+ Log.i(TAG, "size=$size bytes=$bytes days=$dayNumbers bars=$bars")
+ // The grid itself rendered, not Glance's error layout: a number in every day of every week.
+ assertThat(dayNumbers % 7).isEqualTo(0)
+ assertThat(dayNumbers).isAtLeast(28)
+ assertThat(bytes).isAtMost(MAX_BYTES_PER_SIZE)
+ }
+ }
+
+ private companion object {
+ const val TAG = "MonthWidgetPayload"
+ const val ACCOUNT = "calendula-widget-payload-test"
+ const val EVENTS_PER_DAY = 6
+
+ val DAY_IDS = setOf(R.id.day_0, R.id.day_1, R.id.day_2, R.id.day_3, R.id.day_4, R.id.day_5, R.id.day_6)
+
+ /** A quarter of the host's buffer, leaving room for a second size and every other widget. */
+ const val MAX_BYTES_PER_SIZE = 256 * 1024
+ }
+}
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index b7cf5c5..71929e5 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -318,6 +318,16 @@
android:exported="false" />
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/CalendulaApp.kt b/app/src/main/java/de/jeanlucmakiola/calendula/CalendulaApp.kt
index 3f9b88c..d7cc846 100644
--- a/app/src/main/java/de/jeanlucmakiola/calendula/CalendulaApp.kt
+++ b/app/src/main/java/de/jeanlucmakiola/calendula/CalendulaApp.kt
@@ -10,7 +10,9 @@ import de.jeanlucmakiola.calendula.data.contacts.SpecialDatesScheduler
import de.jeanlucmakiola.calendula.data.contacts.SpecialDatesSyncWorker
import de.jeanlucmakiola.calendula.data.reminders.ReminderMaintenanceScheduler
import de.jeanlucmakiola.calendula.data.reminders.ReminderMaintenanceWorker
-import de.jeanlucmakiola.calendula.widget.WidgetRolloverScheduler
+import de.jeanlucmakiola.calendula.widget.publishCalendulaWidgetPreviews
+import de.jeanlucmakiola.calendula.widget.syncWidgetRollover
+import de.jeanlucmakiola.calendula.widget.widgetEntryPoint
import de.jeanlucmakiola.floret.crash.CrashConfig
import de.jeanlucmakiola.floret.crash.CrashReporter
import kotlinx.coroutines.CoroutineScope
@@ -43,6 +45,8 @@ class CalendulaApp : Application() {
chooseIssueUrl = getString(R.string.report_issue_choose_url),
),
)
+ // Before anything that writes, so the widgets see every change (#420).
+ widgetEntryPoint().widgetRefreshCoordinator().start()
reconcileAutoBackup()
reconcileSpecialDates()
reconcileCalendarVisibility()
@@ -55,10 +59,12 @@ class CalendulaApp : Application() {
* (#228). Idempotent, and it covers what no broadcast reaches — an alarm
* dropped by a force-stop is armed again the next time the app is opened.
* Off the main thread: a handful of binder calls on every process start.
+ * The picker previews are republished here too, once per update.
*/
private fun reconcileWidgetRollover() {
CoroutineScope(SupervisorJob() + Dispatchers.Default).launch {
- WidgetRolloverScheduler.sync(this@CalendulaApp)
+ syncWidgetRollover(this@CalendulaApp)
+ publishCalendulaWidgetPreviews(this@CalendulaApp)
}
}
diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/CalendarDataSource.kt b/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/CalendarDataSource.kt
index 7d2101b..62f7670 100644
--- a/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/CalendarDataSource.kt
+++ b/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/CalendarDataSource.kt
@@ -56,6 +56,10 @@ import javax.inject.Singleton
interface CalendarDataSource {
fun calendars(): List
fun instances(beginMillis: Long, endMillis: Long): List
+
+ /** [instances] of event [eventId] alone, soonest first. */
+ fun eventInstances(eventId: Long, beginMillis: Long, endMillis: Long): List =
+ instances(beginMillis, endMillis).filter { it.eventId == eventId }
/**
* [allDayReminderTimeMinutes]: the hour all-day reminders fire at, needed to
* decode their stored offsets back to whole-day lead times (see
@@ -563,21 +567,33 @@ class AndroidCalendarDataSource @Inject constructor(
}
}
- override fun instances(beginMillis: Long, endMillis: Long): List {
+ override fun instances(beginMillis: Long, endMillis: Long): List =
+ queryInstances(beginMillis, endMillis, null, null)
+
+ override fun eventInstances(eventId: Long, beginMillis: Long, endMillis: Long): List =
+ queryInstances(beginMillis, endMillis, "${CalendarContract.Instances.EVENT_ID} = ?", arrayOf(eventId.toString()))
+
+ private fun queryInstances(
+ beginMillis: Long,
+ endMillis: Long,
+ extraSelection: String?,
+ args: Array?,
+ ): List {
ensureObserversRegistered()
+ // Hide cancelled occurrences: "delete only this event" writes a
+ // cancelled exception for the one instance (#47). A NULL status is a
+ // normal, un-cancelled event, so it must survive the filter — a bare
+ // `!= CANCELED` would drop it (NULL != 2 is NULL, not true).
+ // DELETED = 0: a provider that soft-deletes may keep the row's
+ // instances until a purge (#364).
+ val visible = "(${CalendarContract.Instances.STATUS} IS NULL OR " +
+ "${CalendarContract.Instances.STATUS} != ${CalendarContract.Events.STATUS_CANCELED}) AND " +
+ "${CalendarContract.Events.DELETED} = 0"
return resolver.query(
instancesUri(beginMillis, endMillis),
InstanceProjection.COLUMNS,
- // Hide cancelled occurrences: "delete only this event" writes a
- // cancelled exception for the one instance (#47). A NULL status is a
- // normal, un-cancelled event, so it must survive the filter — a bare
- // `!= CANCELED` would drop it (NULL != 2 is NULL, not true).
- // DELETED = 0: a provider that soft-deletes may keep the row's
- // instances until a purge (#364).
- "(${CalendarContract.Instances.STATUS} IS NULL OR " +
- "${CalendarContract.Instances.STATUS} != ${CalendarContract.Events.STATUS_CANCELED}) AND " +
- "${CalendarContract.Events.DELETED} = 0",
- null,
+ if (extraSelection == null) visible else "$visible AND $extraSelection",
+ args,
CalendarContract.Instances.BEGIN + " ASC",
)?.use { c -> c.mapRowsNotNull { toEventInstance() } } ?: emptyList()
}
diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/CalendarRepository.kt b/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/CalendarRepository.kt
index d140a75..e0fa731 100644
--- a/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/CalendarRepository.kt
+++ b/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/CalendarRepository.kt
@@ -16,8 +16,15 @@ interface CalendarRepository {
fun calendars(): Flow>
fun instances(range: ClosedRange): Flow>
+ /** One read of event [eventId]'s occurrences overlapping [range], soonest first, whatever calendar it is in. */
+ suspend fun eventOccurrences(eventId: Long, range: ClosedRange): List
+
/** Re-query every open [calendars] / [instances] flow. */
fun refresh()
+
+ /** Emits on the app's own writes and visibility changes; not on provider notifications or [refresh]. */
+ val localChanges: Flow
+
suspend fun eventDetail(eventId: Long): EventDetail
/**
diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/CalendarRepositoryImpl.kt b/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/CalendarRepositoryImpl.kt
index 27c5166..537fc48 100644
--- a/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/CalendarRepositoryImpl.kt
+++ b/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/CalendarRepositoryImpl.kt
@@ -78,15 +78,28 @@ class CalendarRepositoryImpl @Inject constructor(
ticks.tryEmit(Unit)
}
+ private val writes = MutableSharedFlow(
+ replay = 0,
+ extraBufferCapacity = 1,
+ onBufferOverflow = BufferOverflow.DROP_OLDEST,
+ )
+
/** Run a provider write on [io], then re-query whether or not it threw. */
private suspend fun write(block: suspend () -> T): T = withContext(io) {
try {
block()
} finally {
refresh()
+ writes.tryEmit(Unit)
}
}
+ override val localChanges: Flow = 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
* notifications, plus every change to the pending switch-off set.
@@ -181,6 +194,11 @@ class CalendarRepositoryImpl @Inject constructor(
cachedCalendars
}
+ override suspend fun eventOccurrences(eventId: Long, range: ClosedRange): List =
+ withContext(io) {
+ dataSource.eventInstances(eventId, range.start.toEpochMillis(), range.endInclusive.toEpochMillis())
+ }
+
override suspend fun eventDetail(eventId: Long): EventDetail = withContext(io) {
dataSource.eventDetail(eventId, allDayReminderTimeMinutes())
?: throw NoSuchEventException(eventId)
diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/data/prefs/SettingsPrefs.kt b/app/src/main/java/de/jeanlucmakiola/calendula/data/prefs/SettingsPrefs.kt
index 883424b..3f6a630 100644
--- a/app/src/main/java/de/jeanlucmakiola/calendula/data/prefs/SettingsPrefs.kt
+++ b/app/src/main/java/de/jeanlucmakiola/calendula/data/prefs/SettingsPrefs.kt
@@ -154,8 +154,6 @@ class SettingsPrefs @Inject constructor(
val dragToReschedule: Flow = prefs.flow(SettingsKeys.dragToReschedule)
val tapOpensEditor: Flow = prefs.flow(SettingsKeys.tapOpensEditor)
val agendaScreenRange: Flow = prefs.flow(SettingsKeys.agendaScreenRange)
- val agendaWidgetRange: Flow = prefs.flow(SettingsKeys.agendaWidgetRange)
- val widgetSize: Flow = prefs.flow(SettingsKeys.widgetSize)
val agendaShowRangeBar: Flow = prefs.flow(SettingsKeys.agendaShowRangeBar)
val agendaShowToday: Flow = prefs.flow(SettingsKeys.agendaShowToday)
val defaultView: Flow = prefs.flow(SettingsKeys.defaultView)
@@ -231,8 +229,6 @@ class SettingsPrefs @Inject constructor(
suspend fun setDragToReschedule(enabled: Boolean) = prefs.set(SettingsKeys.dragToReschedule, enabled)
suspend fun setTapOpensEditor(enabled: Boolean) = prefs.set(SettingsKeys.tapOpensEditor, enabled)
suspend fun setAgendaScreenRange(range: AgendaRange) = prefs.set(SettingsKeys.agendaScreenRange, range)
- suspend fun setAgendaWidgetRange(range: AgendaRange) = prefs.set(SettingsKeys.agendaWidgetRange, range)
- suspend fun setWidgetSize(size: WidgetSize) = prefs.set(SettingsKeys.widgetSize, size)
suspend fun setAgendaShowRangeBar(enabled: Boolean) = prefs.set(SettingsKeys.agendaShowRangeBar, enabled)
suspend fun setAgendaShowToday(enabled: Boolean) = prefs.set(SettingsKeys.agendaShowToday, enabled)
suspend fun setDefaultView(view: CalendarView) = prefs.set(SettingsKeys.defaultView, view)
@@ -497,13 +493,16 @@ object SettingsKeys {
/** How far ahead the in-app Agenda screen shows events (v2.11). */
val agendaScreenRange: Pref = agendaRangePref("agenda_screen_range")
- /** How far ahead the agenda **widget** shows events (v2.11). */
+ /**
+ * How far ahead the agenda **widget** shows events (v2.11). Read-only since
+ * widgets keep their own options (#292): the default for a widget that has
+ * none yet.
+ */
val agendaWidgetRange: Pref = agendaRangePref("agenda_widget_range")
/**
* The size step the agenda widget draws its text at (#51). [WidgetSize.SMALL]
- * reproduces its original metrics. The month widget takes no size setting —
- * it divides the width it is given by seven (#103).
+ * reproduces its original metrics. Read-only like [agendaWidgetRange] (#292).
*/
val widgetSize: Pref = enumPref("widget_size", WidgetSize.SMALL)
@@ -511,8 +510,8 @@ object SettingsKeys {
val agendaShowRangeBar: Pref = booleanPref("agenda_show_range_bar", true)
/**
- * Whether the agenda (screen and widget) always anchors today at the top with
- * a "nothing left today" placeholder (issue #35).
+ * Whether the agenda screen always anchors today at the top with a "nothing
+ * left today" placeholder (issue #35), and the default for a widget (#292).
*/
val agendaShowToday: Pref = booleanPref("agenda_show_today", true)
diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/TimeZonePicker.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/TimeZonePicker.kt
index dc055fc..da8da73 100644
--- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/TimeZonePicker.kt
+++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/TimeZonePicker.kt
@@ -101,9 +101,10 @@ fun TimeZonePickerDialog(
onDismiss = onDismiss,
scrollable = false,
) {
- SearchField(
+ PickerSearchField(
query = query,
onQueryChange = { query = it },
+ placeholder = stringResource(R.string.event_edit_timezone_search),
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
)
LazyColumn(contentPadding = PaddingValues(top = 8.dp, bottom = 24.dp)) {
@@ -181,9 +182,10 @@ fun TimeZonePickerDialog(
* which has a top bar to put it in) because the picker's bar is the title.
*/
@Composable
-private fun SearchField(
+internal fun PickerSearchField(
query: String,
onQueryChange: (String) -> Unit,
+ placeholder: String,
modifier: Modifier = Modifier,
) {
val keyboard = LocalSoftwareKeyboardController.current
@@ -206,7 +208,7 @@ private fun SearchField(
InlineTextField(
value = query,
onValueChange = onQueryChange,
- placeholder = stringResource(R.string.event_edit_timezone_search),
+ placeholder = placeholder,
capitalization = KeyboardCapitalization.None,
imeAction = ImeAction.Search,
onImeAction = { keyboard?.hide() },
diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/SettingsScreen.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/SettingsScreen.kt
index 756dbe0..2ffe433 100644
--- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/SettingsScreen.kt
+++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/SettingsScreen.kt
@@ -155,7 +155,7 @@ fun SettingsScreen(
enter = slideInHorizontally(slideSpec) { it } + fadeIn(),
exit = overlayExit,
) {
- WidgetsScreen(state = state, viewModel = viewModel, onBack = { section = null }, visible = section == SettingsSection.Widgets)
+ WidgetsScreen(onBack = { section = null }, visible = section == SettingsSection.Widgets)
}
AnimatedVisibility(
visible = section == SettingsSection.Licences,
diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/SettingsUiState.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/SettingsUiState.kt
index 5d4dcf1..53c14bb 100644
--- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/SettingsUiState.kt
+++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/SettingsUiState.kt
@@ -16,7 +16,6 @@ import de.jeanlucmakiola.calendula.ui.common.QuickSwitchConfig
import de.jeanlucmakiola.calendula.ui.common.TimelineHours
import de.jeanlucmakiola.calendula.ui.common.TimelineScale
import de.jeanlucmakiola.calendula.ui.month.MonthViewStyle
-import de.jeanlucmakiola.calendula.widget.WidgetSize
/**
* Settings screen state (M4). Persisted preferences are instant to read, so
@@ -51,14 +50,10 @@ data class SettingsUiState(
val tapOpensEditor: Boolean = false,
/** How far ahead the in-app Agenda screen shows events (v2.11). */
val agendaScreenRange: AgendaRange = AgendaRange.Month,
- /** How far ahead the agenda widget shows events (v2.11). */
- val agendaWidgetRange: AgendaRange = AgendaRange.Month,
- /** Whether the agenda (screen + widget) always anchors today at the top (#35). */
+ /** Whether the agenda screen always anchors today at the top (#35); widgets have their own (#292). */
val agendaShowToday: Boolean = true,
/** Whether the agenda shows its top range bar — header + switcher (v2.11). */
val agendaShowRangeBar: Boolean = true,
- /** The size step the agenda widget draws its text at (#51). */
- val widgetSize: WidgetSize = WidgetSize.SMALL,
/** The calendar view the app opens on, and the home of the view back stack (M1). */
val defaultView: CalendarView = CalendarView.Week,
/** Which views the top-bar quick-switch button cycles through, and their order (#24). */
diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/SettingsViewModel.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/SettingsViewModel.kt
index cbbe707..8326032 100644
--- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/SettingsViewModel.kt
+++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/SettingsViewModel.kt
@@ -3,9 +3,6 @@ package de.jeanlucmakiola.calendula.ui.settings
import android.content.Context
import android.net.Uri
import android.os.Build
-import androidx.glance.appwidget.GlanceAppWidgetManager
-import androidx.glance.appwidget.state.updateAppWidgetState
-import androidx.glance.appwidget.updateAll
import androidx.datastore.preferences.core.Preferences
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
@@ -42,20 +39,12 @@ import de.jeanlucmakiola.calendula.domain.EventFormField
import de.jeanlucmakiola.calendula.domain.FontRole
import de.jeanlucmakiola.calendula.domain.contacts.SpecialDateType
import de.jeanlucmakiola.calendula.ui.agenda.AgendaRange
-import de.jeanlucmakiola.calendula.ui.agenda.storageValue
import de.jeanlucmakiola.calendula.ui.common.CalendarView
import de.jeanlucmakiola.calendula.ui.common.TimelineHours
import de.jeanlucmakiola.calendula.ui.common.TimelineScale
import de.jeanlucmakiola.calendula.ui.month.MonthViewStyle
import de.jeanlucmakiola.calendula.ui.theme.AppFontSettings
import de.jeanlucmakiola.calendula.ui.theme.FONT_CUSTOM_TOKEN
-import de.jeanlucmakiola.calendula.widget.WidgetSize
-import de.jeanlucmakiola.calendula.widget.agenda.AGENDA_PAST_DISPLAY_KEY
-import de.jeanlucmakiola.calendula.widget.agenda.AGENDA_RANGE_KEY
-import de.jeanlucmakiola.calendula.widget.agenda.AGENDA_SHOW_TODAY_STATE_KEY
-import de.jeanlucmakiola.calendula.widget.agenda.AGENDA_SIZE_KEY
-import de.jeanlucmakiola.calendula.widget.agenda.AgendaWidget
-import de.jeanlucmakiola.calendula.widget.month.MonthWidget
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableSharedFlow
@@ -67,8 +56,6 @@ import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.launch
-import kotlinx.coroutines.sync.Mutex
-import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext
import javax.inject.Inject
@@ -118,10 +105,8 @@ class SettingsViewModel @Inject constructor(
dragToReschedule = this[SettingsKeys.dragToReschedule],
tapOpensEditor = this[SettingsKeys.tapOpensEditor],
agendaScreenRange = this[SettingsKeys.agendaScreenRange],
- agendaWidgetRange = this[SettingsKeys.agendaWidgetRange],
agendaShowToday = this[SettingsKeys.agendaShowToday],
agendaShowRangeBar = this[SettingsKeys.agendaShowRangeBar],
- widgetSize = this[SettingsKeys.widgetSize],
defaultView = this[SettingsKeys.defaultView],
quickSwitchConfig = this[SettingsKeys.quickSwitchConfig],
monthViewStyle = this[SettingsKeys.monthViewStyle],
@@ -181,10 +166,6 @@ class SettingsViewModel @Inject constructor(
private val _fontImportFailed = MutableSharedFlow(extraBufferCapacity = 1)
val fontImportFailed: SharedFlow = _fontImportFailed
- // Serialises widget redraws: concurrent updateAll calls coalesce around a
- // stale read and can leave the widget on an intermediate value.
- private val widgetRefreshMutex = Mutex()
-
/** Contact special-dates sub-page (issue #15). */
val specialDatesState: StateFlow = prefs.data.map { stored ->
val templates = stored.specialDatesTitleTemplates
@@ -267,16 +248,7 @@ class SettingsViewModel @Inject constructor(
fun setDynamicColor(enabled: Boolean) = save { setDynamicColor(enabled) }
- fun setSoftenColors(enabled: Boolean) {
- viewModelScope.launch {
- prefs.setSoftenCalendarColors(enabled)
- // Both widgets paint event colours through the same softener.
- widgetRefreshMutex.withLock {
- AgendaWidget().updateAll(appContext)
- MonthWidget().updateAll(appContext)
- }
- }
- }
+ fun setSoftenColors(enabled: Boolean) = save { setSoftenCalendarColors(enabled) }
fun setFont(role: FontRole, token: String) = save {
when (role) {
@@ -304,68 +276,13 @@ class SettingsViewModel @Inject constructor(
}
}
- fun setWeekStart(pref: WeekStartPref) {
- viewModelScope.launch {
- prefs.setWeekStart(pref)
- // Both widgets depend on the week start (month weekday header; the
- // agenda widget's "this week" range).
- widgetRefreshMutex.withLock {
- AgendaWidget().updateAll(appContext)
- MonthWidget().updateAll(appContext)
- }
- }
- }
+ fun setWeekStart(pref: WeekStartPref) = save { setWeekStart(pref) }
fun setAgendaScreenRange(range: AgendaRange) = save { setAgendaScreenRange(range) }
- fun setAgendaWidgetRange(range: AgendaRange) {
- viewModelScope.launch {
- prefs.setAgendaWidgetRange(range)
- widgetRefreshMutex.withLock {
- // Push into each instance's Glance state and recompose:
- // updateAll alone won't re-run provideGlance's data preamble.
- val manager = GlanceAppWidgetManager(appContext)
- manager.getGlanceIds(AgendaWidget::class.java).forEach { id ->
- updateAppWidgetState(appContext, id) { it[AGENDA_RANGE_KEY] = range.storageValue() }
- }
- AgendaWidget().updateAll(appContext)
- }
- }
- }
-
fun setAgendaShowRangeBar(enabled: Boolean) = save { setAgendaShowRangeBar(enabled) }
- /**
- * Set the size the agenda widget draws its text at (#51), via the same
- * Glance-state path as [setAgendaWidgetRange]. Agenda-only: the month widget
- * sizes itself from the space it is given (#103).
- */
- fun setWidgetSize(size: WidgetSize) {
- viewModelScope.launch {
- prefs.setWidgetSize(size)
- widgetRefreshMutex.withLock {
- val manager = GlanceAppWidgetManager(appContext)
- manager.getGlanceIds(AgendaWidget::class.java).forEach { id ->
- updateAppWidgetState(appContext, id) { it[AGENDA_SIZE_KEY] = size.name }
- }
- AgendaWidget().updateAll(appContext)
- }
- }
- }
-
- fun setAgendaShowToday(enabled: Boolean) {
- viewModelScope.launch {
- prefs.setAgendaShowToday(enabled)
- // Same Glance-state path as setPastEventDisplay.
- widgetRefreshMutex.withLock {
- val manager = GlanceAppWidgetManager(appContext)
- manager.getGlanceIds(AgendaWidget::class.java).forEach { id ->
- updateAppWidgetState(appContext, id) { it[AGENDA_SHOW_TODAY_STATE_KEY] = enabled }
- }
- AgendaWidget().updateAll(appContext)
- }
- }
- }
+ fun setAgendaShowToday(enabled: Boolean) = save { setAgendaShowToday(enabled) }
fun setTimeFormat(pref: TimeFormatPref) = save { setTimeFormat(pref) }
@@ -396,19 +313,7 @@ class SettingsViewModel @Inject constructor(
}
}
- fun setPastEventDisplay(mode: PastEventDisplay) {
- viewModelScope.launch {
- prefs.setPastEventDisplay(mode)
- // Same Glance-state path as setAgendaWidgetRange.
- widgetRefreshMutex.withLock {
- val manager = GlanceAppWidgetManager(appContext)
- manager.getGlanceIds(AgendaWidget::class.java).forEach { id ->
- updateAppWidgetState(appContext, id) { it[AGENDA_PAST_DISPLAY_KEY] = mode.name }
- }
- AgendaWidget().updateAll(appContext)
- }
- }
- }
+ fun setPastEventDisplay(mode: PastEventDisplay) = save { setPastEventDisplay(mode) }
fun setDimCompletedEvents(enabled: Boolean) = save { setDimCompletedEvents(enabled) }
diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/WidgetSettings.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/WidgetSettings.kt
index 456efbe..c383197 100644
--- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/WidgetSettings.kt
+++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/WidgetSettings.kt
@@ -10,7 +10,9 @@ import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.height
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
-import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.mutableIntStateOf
+import androidx.compose.runtime.produceState
+import androidx.lifecycle.compose.LifecycleResumeEffect
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
@@ -18,56 +20,46 @@ import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import de.jeanlucmakiola.calendula.R
-import de.jeanlucmakiola.calendula.data.prefs.resolveFirstDay
import de.jeanlucmakiola.calendula.qs.NewEventTileService
-import de.jeanlucmakiola.calendula.ui.common.AgendaRangePicker
-import de.jeanlucmakiola.calendula.ui.common.agendaRangeLabel
-import de.jeanlucmakiola.calendula.widget.WidgetSize
-import de.jeanlucmakiola.calendula.widget.agenda.metricsFor
import de.jeanlucmakiola.floret.components.CollapsingScaffold
import de.jeanlucmakiola.floret.components.GroupedHint
import de.jeanlucmakiola.floret.components.GroupedRow
-import de.jeanlucmakiola.floret.components.OptionPicker
import de.jeanlucmakiola.floret.components.Position
-import de.jeanlucmakiola.floret.locale.currentLocale
-import kotlin.math.roundToInt
+import de.jeanlucmakiola.floret.components.GroupedSectionHeader
+import de.jeanlucmakiola.floret.components.positionOf
+import de.jeanlucmakiola.calendula.widget.config.PlacedWidget
+import de.jeanlucmakiola.calendula.widget.config.WidgetConfigActivity
+import de.jeanlucmakiola.calendula.widget.WidgetKind
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.withContext
+import de.jeanlucmakiola.calendula.widget.config.backgroundLabel
+import de.jeanlucmakiola.calendula.widget.config.placedWidgets
+import de.jeanlucmakiola.calendula.widget.config.themeLabel
/**
- * Widgets & tiles (#69): the home-screen widgets' settings and the Quick
- * Settings tile shortcut. The agenda widget keeps its own range and text size,
- * separate from the Agenda screen's range in [ViewsScreen].
+ * Widgets & tiles (#69): the placed home-screen widgets, each opening its own
+ * config screen (#292), and the Quick Settings tile shortcut.
*/
@Composable
internal fun WidgetsScreen(
- state: SettingsUiState,
- viewModel: SettingsViewModel,
onBack: () -> Unit,
visible: Boolean = true,
) {
- var showAgendaWidgetRange by remember { mutableStateOf(false) }
- var showWidgetSize by remember { mutableStateOf(false) }
-
CollapsingScaffold(
title = stringResource(R.string.settings_section_widgets),
onBack = onBack,
predictiveBack = true,
visible = visible,
) {
- GroupedHint(stringResource(R.string.settings_widgets_hint))
- Spacer(Modifier.height(8.dp))
-
- GroupedRow(
- title = stringResource(R.string.settings_agenda_widget_range),
- summary = agendaRangeLabel(state.agendaWidgetRange),
- position = Position.Top,
- onClick = { showAgendaWidgetRange = true },
- )
- GroupedRow(
- title = stringResource(R.string.settings_widget_size),
- summary = widgetSizeLabel(state.widgetSize),
- position = Position.Bottom,
- onClick = { showWidgetSize = true },
+ GroupedHint(
+ stringResource(
+ // The launcher's "Edit widget" action only exists from Android 12.
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) R.string.settings_widgets_hint
+ else R.string.settings_widgets_hint_legacy,
+ ),
)
+ GroupedSectionHeader(stringResource(R.string.settings_widgets_placed))
+ PlacedWidgetRows()
// The add-tile prompt is API 33+; below that the tile is still
// addable from the QS editor, so the row just doesn't appear.
@@ -76,30 +68,44 @@ internal fun WidgetsScreen(
QuickSettingsTileRow()
}
}
+}
- if (showAgendaWidgetRange) {
- AgendaRangePicker(
- title = stringResource(R.string.settings_agenda_widget_range),
- description = stringResource(R.string.settings_agenda_widget_range_hint),
- selected = state.agendaWidgetRange,
- weekStart = state.weekStart.resolveFirstDay(currentLocale()),
- onSelect = viewModel::setAgendaWidgetRange,
- onDismiss = { showAgendaWidgetRange = false },
- )
+@Composable
+private fun PlacedWidgetRows() {
+ val context = LocalContext.current
+ // Read on every resume, the first included, so a widget added, removed or
+ // edited meanwhile shows up.
+ var refresh by remember { mutableIntStateOf(0) }
+ LifecycleResumeEffect(Unit) {
+ refresh++
+ onPauseOrDispose {}
}
- if (showWidgetSize) {
- OptionPicker(
- title = stringResource(R.string.settings_widget_size),
- header = { GroupedHint(stringResource(R.string.settings_widget_size_hint), verticalPadding = 8.dp) },
- predictiveBack = true,
- options = WidgetSize.entries,
- selected = state.widgetSize,
- label = { widgetSizeLabel(it) },
- // No live preview: the widget is Glance/RemoteViews, so a Compose
- // mock-up would be a second implementation free to drift.
- summary = { widgetSizeSummary(it) },
- onSelect = viewModel::setWidgetSize,
- onDismiss = { showWidgetSize = false },
+ val widgets by produceState?>(initialValue = null, refresh) {
+ if (refresh > 0) value = withContext(Dispatchers.IO) { context.placedWidgets() }
+ }
+ val placed = widgets ?: return
+ if (placed.isEmpty()) {
+ GroupedRow(
+ title = stringResource(R.string.settings_widgets_none),
+ position = Position.Alone,
+ dimmed = true,
+ )
+ return
+ }
+ val kindCounts = placed.groupingBy { it.kind }.eachCount()
+ val kindSeen = mutableMapOf()
+ placed.forEachIndexed { index, widget ->
+ val number = kindSeen.merge(widget.kind, 1, Int::plus)!!
+ val label = stringResource(widget.kind.label)
+ GroupedRow(
+ title = if (kindCounts.getValue(widget.kind) > 1) stringResource(R.string.widget_numbered, label, number) else label,
+ summary = stringResource(
+ R.string.widget_appearance_summary,
+ backgroundLabel(widget.appearance.background),
+ themeLabel(widget.appearance.theme),
+ ),
+ position = positionOf(index, placed.size),
+ onClick = { context.startActivity(WidgetConfigActivity.intent(context, widget.appWidgetId)) },
)
}
}
@@ -131,24 +137,3 @@ private fun requestAddQsTile(context: Context) {
context.mainExecutor,
) { /* result code unused — the system surfaces its own feedback */ }
}
-
-/**
- * What a size step does, as a percentage of the smallest one's event text.
- * Derived from the widget's own [metricsFor] table rather than hardcoded.
- */
-@Composable
-private fun widgetSizeSummary(size: WidgetSize): String {
- val base = metricsFor(WidgetSize.SMALL).eventTitle.value
- val percent = (metricsFor(size).eventTitle.value / base * 100f).roundToInt()
- return stringResource(R.string.settings_widget_size_summary, percent)
-}
-
-@Composable
-private fun widgetSizeLabel(size: WidgetSize): String = stringResource(
- when (size) {
- WidgetSize.SMALL -> R.string.settings_widget_size_small
- WidgetSize.MEDIUM -> R.string.settings_widget_size_medium
- WidgetSize.LARGE -> R.string.settings_widget_size_large
- WidgetSize.EXTRA_LARGE -> R.string.settings_widget_size_extra_large
- },
-)
diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/widget/CalendulaWidgetReceiver.kt b/app/src/main/java/de/jeanlucmakiola/calendula/widget/CalendulaWidgetReceiver.kt
new file mode 100644
index 0000000..dbc12e0
--- /dev/null
+++ b/app/src/main/java/de/jeanlucmakiola/calendula/widget/CalendulaWidgetReceiver.kt
@@ -0,0 +1,44 @@
+package de.jeanlucmakiola.calendula.widget
+
+import android.annotation.SuppressLint
+import android.appwidget.AppWidgetManager
+import android.content.Context
+import androidx.glance.appwidget.GlanceAppWidgetReceiver
+import de.jeanlucmakiola.floret.glance.reloadOnUpdate
+import de.jeanlucmakiola.floret.glance.restoreState
+
+/**
+ * What every widget receiver shares: the midnight rollover follows whether any
+ * widget is placed (#228), a restore keeps each widget's settings (#292), and
+ * the system's `updatePeriodMillis` wake-up reloads the data and re-arms the
+ * alarm — anything that drops a pending alarm without a broadcast (force-stop,
+ * battery restriction, an OEM freeze) is repaired there rather than on the
+ * next app open.
+ */
+abstract class CalendulaWidgetReceiver : GlanceAppWidgetReceiver() {
+
+ override fun onEnabled(context: Context) {
+ super.onEnabled(context)
+ syncWidgetRollover(context)
+ }
+
+ override fun onDisabled(context: Context) {
+ super.onDisabled(context)
+ syncWidgetRollover(context)
+ }
+
+ override fun onRestored(context: Context, oldWidgetIds: IntArray, newWidgetIds: IntArray) {
+ restoreState(context, oldWidgetIds, newWidgetIds)
+ }
+
+ @SuppressLint("MissingSuperCall")
+ override fun onUpdate(
+ context: Context,
+ appWidgetManager: AppWidgetManager,
+ appWidgetIds: IntArray,
+ ) {
+ // In place of super, which only recomposes and would take the goAsync handle.
+ reloadOnUpdate(context, appWidgetIds)
+ syncWidgetRollover(context)
+ }
+}
diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/widget/WidgetConfig.kt b/app/src/main/java/de/jeanlucmakiola/calendula/widget/WidgetConfig.kt
new file mode 100644
index 0000000..32258b8
--- /dev/null
+++ b/app/src/main/java/de/jeanlucmakiola/calendula/widget/WidgetConfig.kt
@@ -0,0 +1,105 @@
+package de.jeanlucmakiola.calendula.widget
+
+import android.content.Context
+import androidx.datastore.preferences.core.MutablePreferences
+import androidx.datastore.preferences.core.Preferences
+import androidx.datastore.preferences.core.booleanPreferencesKey
+import androidx.datastore.preferences.core.stringPreferencesKey
+import androidx.datastore.preferences.core.stringSetPreferencesKey
+import androidx.glance.GlanceId
+import androidx.glance.appwidget.state.getAppWidgetState
+import androidx.glance.state.PreferencesGlanceStateDefinition
+import de.jeanlucmakiola.calendula.data.prefs.PastEventDisplay
+import de.jeanlucmakiola.calendula.domain.EventInstance
+import de.jeanlucmakiola.calendula.ui.agenda.AgendaRange
+import de.jeanlucmakiola.calendula.ui.agenda.parseAgendaRange
+import de.jeanlucmakiola.calendula.ui.agenda.storageValue
+import de.jeanlucmakiola.floret.glance.WidgetBackground
+import de.jeanlucmakiola.floret.glance.WidgetThemeMode
+import de.jeanlucmakiola.floret.prefs.ThemeMode
+import de.jeanlucmakiola.floret.prefs.toEnum
+
+/** A widget's own light/dark choice (#292); [FOLLOW_APP] tracks the app's theme setting. */
+enum class WidgetTheme { FOLLOW_APP, LIGHT, DARK }
+
+/** How a widget looks, set per placed widget in its config screen (#292). */
+data class WidgetAppearance(
+ val background: WidgetBackground = WidgetBackground.TINTED,
+ val theme: WidgetTheme = WidgetTheme.FOLLOW_APP,
+)
+
+/** What the agenda widget lists, per placed widget. */
+data class AgendaWidgetOptions(
+ val range: AgendaRange,
+ val textSize: WidgetSize,
+ val showToday: Boolean,
+ val pastDisplay: PastEventDisplay,
+)
+
+internal fun WidgetTheme.resolve(app: ThemeMode): WidgetThemeMode = when (this) {
+ WidgetTheme.LIGHT -> WidgetThemeMode.LIGHT
+ WidgetTheme.DARK -> WidgetThemeMode.DARK
+ WidgetTheme.FOLLOW_APP -> when (app) {
+ ThemeMode.SYSTEM -> WidgetThemeMode.SYSTEM
+ ThemeMode.LIGHT -> WidgetThemeMode.LIGHT
+ ThemeMode.DARK, ThemeMode.BLACK -> WidgetThemeMode.DARK
+ }
+}
+
+private val BackgroundKey = stringPreferencesKey("cfg_background")
+private val ThemeKey = stringPreferencesKey("cfg_theme")
+private val AgendaRangeKey = stringPreferencesKey("cfg_agenda_range")
+private val TextSizeKey = stringPreferencesKey("cfg_text_size")
+private val ShowTodayKey = booleanPreferencesKey("cfg_show_today")
+private val PastDisplayKey = stringPreferencesKey("cfg_past_display")
+private val HiddenCalendarsKey = stringSetPreferencesKey("cfg_hidden_calendars")
+
+internal val Preferences.widgetAppearance: WidgetAppearance
+ get() = WidgetAppearance(
+ background = this[BackgroundKey].toEnum(WidgetBackground.TINTED),
+ theme = this[ThemeKey].toEnum(WidgetTheme.FOLLOW_APP),
+ )
+
+/**
+ * This widget's agenda options. A widget placed before per-widget options
+ * existed has none stored, and keeps following the app-wide values in
+ * [defaults] until it is configured.
+ */
+internal fun Preferences.agendaWidgetOptions(defaults: AgendaWidgetOptions) = AgendaWidgetOptions(
+ range = parseAgendaRange(this[AgendaRangeKey], defaults.range),
+ textSize = this[TextSizeKey].toEnum(defaults.textSize),
+ showToday = this[ShowTodayKey] ?: defaults.showToday,
+ pastDisplay = this[PastDisplayKey].toEnum(defaults.pastDisplay),
+)
+
+/** Whether this widget has agenda options of its own, rather than following the app's. */
+internal val Preferences.hasAgendaOptions: Boolean get() = this[AgendaRangeKey] != null
+
+internal fun MutablePreferences.putAppearance(appearance: WidgetAppearance) {
+ this[BackgroundKey] = appearance.background.name
+ this[ThemeKey] = appearance.theme.name
+}
+
+internal fun MutablePreferences.putAgendaOptions(options: AgendaWidgetOptions) {
+ this[AgendaRangeKey] = options.range.storageValue()
+ this[TextSizeKey] = options.textSize.name
+ this[ShowTodayKey] = options.showToday
+ this[PastDisplayKey] = options.pastDisplay.name
+}
+
+/**
+ * Calendars this widget leaves out, on top of the ones hidden app-wide. Stored
+ * as the excluded set, so a calendar added later shows up by default.
+ */
+internal val Preferences.hiddenCalendars: Set
+ get() = this[HiddenCalendarsKey]?.mapNotNull { it.toLongOrNull() }?.toSet().orEmpty()
+
+internal fun MutablePreferences.putHiddenCalendars(ids: Set) {
+ this[HiddenCalendarsKey] = ids.map(Long::toString).toSet()
+}
+
+internal fun List.withoutCalendars(hidden: Set): List =
+ if (hidden.isEmpty()) this else filterNot { it.calendarId in hidden }
+
+internal suspend fun Context.widgetState(id: GlanceId): Preferences =
+ getAppWidgetState(this, PreferencesGlanceStateDefinition, id)
diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/widget/WidgetData.kt b/app/src/main/java/de/jeanlucmakiola/calendula/widget/WidgetData.kt
index e902b59..cc99c55 100644
--- a/app/src/main/java/de/jeanlucmakiola/calendula/widget/WidgetData.kt
+++ b/app/src/main/java/de/jeanlucmakiola/calendula/widget/WidgetData.kt
@@ -4,7 +4,12 @@ import android.Manifest
import android.content.Context
import android.content.pm.PackageManager
import androidx.core.content.ContextCompat
+import androidx.glance.GlanceId
import de.jeanlucmakiola.calendula.data.prefs.PastEventDisplay
+import de.jeanlucmakiola.calendula.data.prefs.SettingsKeys
+import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
+import de.jeanlucmakiola.calendula.data.prefs.TimeFormatPref
+import de.jeanlucmakiola.calendula.data.prefs.WeekStartPref
import de.jeanlucmakiola.calendula.data.prefs.is24Hour
import de.jeanlucmakiola.calendula.data.prefs.resolveFirstDay
import de.jeanlucmakiola.calendula.domain.EventInstance
@@ -13,7 +18,12 @@ import de.jeanlucmakiola.calendula.ui.agenda.AgendaRange
import de.jeanlucmakiola.calendula.ui.agenda.agendaRange
import de.jeanlucmakiola.calendula.ui.agenda.dayCount
import de.jeanlucmakiola.calendula.ui.agenda.groupAgendaDays
+import de.jeanlucmakiola.floret.prefs.ThemeMode
+import de.jeanlucmakiola.floret.prefs.get
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.first
+import kotlinx.coroutines.flow.map
import kotlinx.datetime.DateTimeUnit
import kotlinx.datetime.DayOfWeek
import kotlinx.datetime.LocalDate
@@ -25,6 +35,7 @@ import kotlinx.datetime.plus
import kotlinx.datetime.toInstant
import kotlinx.datetime.toLocalDateTime
import java.util.Locale
+import java.util.concurrent.ConcurrentHashMap
import kotlin.time.Clock
import kotlin.time.Instant
@@ -34,10 +45,63 @@ import kotlin.time.Instant
* the whole window, and on slow devices a wide one held the widget on "loading"
* (#363).
*/
-private const val MONTH_WIDGET_PAD_MONTHS = 3
+internal const val MONTH_WIDGET_PAD_MONTHS = 3
+
+/**
+ * Every app setting the widgets draw with: what the loads read, and what
+ * [WidgetRefreshCoordinator] watches. The agenda fields are only the defaults
+ * for a widget that has no options of its own yet.
+ */
+internal data class WidgetSettings(
+ val soften: Boolean,
+ val weekStart: WeekStartPref,
+ val timeFormat: TimeFormatPref,
+ val themeMode: ThemeMode,
+ val pastDisplay: PastEventDisplay,
+ val agendaRange: AgendaRange,
+ val widgetSize: WidgetSize,
+ val showToday: Boolean,
+) {
+ /** What every widget draws with; the agenda defaults aside. */
+ val shared: SharedWidgetSettings get() = SharedWidgetSettings(soften, weekStart, timeFormat, themeMode)
+
+ val agendaDefaults: AgendaWidgetOptions
+ get() = AgendaWidgetOptions(agendaRange, widgetSize, showToday, pastDisplay)
+}
+
+/** The [WidgetSettings] a configured widget still takes from the app. */
+internal data class SharedWidgetSettings(
+ val soften: Boolean,
+ val weekStart: WeekStartPref,
+ val timeFormat: TimeFormatPref,
+ val themeMode: ThemeMode,
+)
+
+/** A widget's loaded [content], with the app theme a [WidgetTheme.FOLLOW_APP] widget resolves against. */
+internal data class Themed(val content: T, val appTheme: ThemeMode)
+
+internal val SettingsPrefs.widgetSettings: Flow
+ get() = data.map {
+ WidgetSettings(
+ soften = it[SettingsKeys.softenCalendarColors],
+ weekStart = it[SettingsKeys.weekStart],
+ timeFormat = it[SettingsKeys.timeFormat],
+ themeMode = it[SettingsKeys.themeMode],
+ pastDisplay = it[SettingsKeys.pastEventDisplay],
+ agendaRange = it[SettingsKeys.agendaWidgetRange],
+ widgetSize = it[SettingsKeys.widgetSize],
+ showToday = it[SettingsKeys.agendaShowToday],
+ )
+ }.distinctUntilChanged()
internal fun systemZone(): TimeZone = TimeZone.currentSystemDefault()
+/**
+ * The locale widget text is written in. Glance has no LocalConfiguration, and a
+ * widget is redrawn on a locale change anyway (LOCALE_CHANGED), so a plain read is enough.
+ */
+internal fun widgetLocale(): Locale = Locale.getDefault()
+
internal fun today(zone: TimeZone): LocalDate =
Clock.System.now().toLocalDateTime(zone).date
@@ -51,112 +115,74 @@ sealed interface AgendaWidgetData {
data object NeedsPermission : AgendaWidgetData
data class Ready(
val today: LocalDate,
- /**
- * Days with events across the **widest** selectable window. The displayed
- * range is sliced from this in the composition (read reactively from
- * Glance state), so a range change is pure recomposition rather than a
- * flaky widget session restart — the same approach the month widget uses.
- * Kept unfiltered: hiding/dimming finished events also happens reactively
- * in the composition, against [now], for the same reliable-update reason.
- */
+ /** Days with events across the configured range, unfiltered; hiding finished ones happens in the composition. */
val days: List,
/** Resolved clock convention for event time labels (the time-format pref). */
val is24Hour: Boolean,
/** Whether event colours are softened to pastels, or shown raw (#36). */
val soften: Boolean,
- /** First day of the week, for the calendar-aligned "this week" range. */
- val weekStart: DayOfWeek,
- /** Saved range pref — the fallback when an instance has no Glance state yet. */
- val savedRange: AgendaRange,
- /** Saved past-event display pref — the fallback before Glance state is set. */
- val savedPastDisplay: PastEventDisplay,
- /**
- * Saved "always show today" pref (#35) — anchors today at the top with a
- * placeholder even when it has no events left. The fallback read reactively
- * from Glance state before an instance has its own state set.
- */
- val savedShowToday: Boolean,
- /** Saved widget size step (#103) — the fallback before Glance state is set. */
- val savedWidgetSize: WidgetSize,
- /** Last day [days] covers; a wider range picked later needs a reload. */
- val loadedThrough: LocalDate,
+ val pastDisplay: PastEventDisplay,
+ /** "Always show today" (#35): anchor today at the top even with no events left. */
+ val showToday: Boolean,
+ val textSize: WidgetSize,
/** Snapshot instant the data was read at, for "has this event ended?" tests. */
val now: Instant,
) : AgendaWidgetData
}
/**
- * Source data for the month widget: a wide window of instances plus the
- * week-start preference and today. The widget computes each displayed month's
- * grid from this in-memory list (via `layoutMonthWeeks`) as the user pages,
- * so month navigation is pure recomposition — no reload, no flaky widget
- * session restart.
+ * Source data for the month widget: a window of instances plus the week-start
+ * preference and today. The widget computes each displayed month's grid from
+ * this in-memory list (via `layoutMonthWeeks`) as the user pages, so paging
+ * inside the loaded window is pure recomposition.
*/
sealed interface MonthWidgetSource {
data object NeedsPermission : MonthWidgetSource
data class Ready(
val today: LocalDate,
val weekStart: DayOfWeek,
+ val soften: Boolean,
val instances: List,
/** Month indices (`year * 12 + monthOrdinal`) whose grids [instances] covers. */
val months: IntRange,
) : MonthWidgetSource
}
-/**
- * Process-lived cache of the month window. Month navigation can re-run
- * `provideGlance`, and re-querying on every arrow tap is what made paging feel
- * sluggish — so we load once and reuse the same snapshot for every nearby month. Invalidated by
- * [invalidateMonthWidgetCache] when calendar data changes (the freshness
- * receiver), and automatically when the day rolls over (the `today` guard).
- */
-internal object MonthWidgetCache {
- @Volatile
- var data: MonthWidgetSource.Ready? = null
-}
-
-internal fun invalidateMonthWidgetCache() {
- MonthWidgetCache.data = null
+/** [loadAgendaWidgetData] with agenda widget [id]'s own options. */
+internal suspend fun Context.loadAgendaWidget(id: GlanceId): Themed {
+ val settings = widgetEntryPoint().settingsPrefs().widgetSettings.first()
+ val state = widgetState(id)
+ val options = state.agendaWidgetOptions(settings.agendaDefaults)
+ return Themed(loadAgendaWidgetData(settings, options, state.hiddenCalendars), settings.themeMode)
}
/**
* One-shot read of the upcoming agenda for the widget. Reuses the app's
* [agendaRange] window and [groupAgendaDays] grouping, and the repository's
* [first]-emitted snapshot already has hidden calendars filtered out.
- *
- * 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(
+ settings: WidgetSettings,
+ options: AgendaWidgetOptions,
+ hiddenCalendars: Set,
+): AgendaWidgetData {
if (!hasCalendarPermission()) return AgendaWidgetData.NeedsPermission
val zone = systemZone()
val anchor = today(zone)
val ep = widgetEntryPoint()
- val prefs = ep.settingsPrefs()
- val savedRange = prefs.agendaWidgetRange.first()
- val savedPastDisplay = prefs.pastEventDisplay.first()
- val showToday = prefs.agendaShowToday.first()
- val 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 weekStart = settings.weekStart.resolveFirstDay(Locale.getDefault())
+ val dayCount = options.range.dayCount(anchor, weekStart)
val window = agendaRange(anchor, dayCount - 1, zone)
val windowEnd = anchor.plus(dayCount - 1, DateTimeUnit.DAY)
- val instances = ep.calendarRepository().instances(window).first()
- val is24Hour = prefs.timeFormat.first()
- .is24Hour(android.text.format.DateFormat.is24HourFormat(this))
- val soften = prefs.softenCalendarColors.first()
+ val instances = ep.calendarRepository().instances(window).first().withoutCalendars(hiddenCalendars)
return AgendaWidgetData.Ready(
today = anchor,
days = groupAgendaDays(anchor, windowEnd, instances, zone),
- is24Hour = is24Hour,
- soften = soften,
- weekStart = weekStart,
- savedRange = savedRange,
- savedPastDisplay = savedPastDisplay,
- savedShowToday = showToday,
- savedWidgetSize = prefs.widgetSize.first(),
- loadedThrough = windowEnd,
+ is24Hour = settings.timeFormat.is24Hour(android.text.format.DateFormat.is24HourFormat(this)),
+ soften = settings.soften,
+ pastDisplay = options.pastDisplay,
+ showToday = options.showToday,
+ textSize = options.textSize,
now = Clock.System.now(),
)
}
@@ -165,17 +191,55 @@ internal fun monthIndexOf(date: LocalDate): Int = date.year * 12 + date.month.or
private fun firstDayOfMonthIndex(index: Int): LocalDate = LocalDate(index / 12, index % 12 + 1, 1)
-/** Read backing the month widget's grid for [centerIndex] and the months around it. */
-internal suspend fun Context.loadMonthWidgetSource(centerIndex: Int? = null): MonthWidgetSource {
+/**
+ * The month the widget loads its window around while [index] is shown. Snapped
+ * to steps of three, so paging only reloads every third month, and the window
+ * always reaches at least two months past [index] either way.
+ */
+internal fun monthWindowCenter(index: Int): Int = Math.floorDiv(index, 3) * 3 + 1
+
+/**
+ * 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: Themed)
+
+ private val entries = ConcurrentHashMap()
+
+ fun get(id: GlanceId, center: Int, stamp: Long): Themed? =
+ entries[id]?.takeIf { it.center == center && it.stamp == stamp && it.source.content.today == today(systemZone()) }
+ ?.source
+
+ fun put(id: GlanceId, center: Int, stamp: Long, source: Themed) {
+ val ready = source.content as? MonthWidgetSource.Ready ?: return
+ entries[id] = Entry(center, stamp, Themed(ready, source.appTheme))
+ }
+}
+
+/** [loadThemedMonthSource] for widget [id], served from [MonthSourceCache] while its [stamp] holds. */
+internal suspend fun Context.loadCachedMonthSource(id: GlanceId, centerIndex: Int, stamp: Long): Themed =
+ MonthSourceCache.get(id, centerIndex, stamp)
+ ?: loadThemedMonthSource(centerIndex, widgetState(id).hiddenCalendars)
+ .also { MonthSourceCache.put(id, centerIndex, stamp, it) }
+
+/** Read backing the month widget's grid for the window around [centerIndex], with the app theme. */
+internal suspend fun Context.loadThemedMonthSource(centerIndex: Int, hiddenCalendars: Set): Themed {
+ val settings = widgetEntryPoint().settingsPrefs().widgetSettings.first()
+ return Themed(queryMonthSource(centerIndex, settings, hiddenCalendars), settings.themeMode)
+}
+
+private suspend fun Context.queryMonthSource(
+ centerIndex: Int,
+ settings: WidgetSettings,
+ hiddenCalendars: Set,
+): MonthWidgetSource {
if (!hasCalendarPermission()) return MonthWidgetSource.NeedsPermission
val zone = systemZone()
- val anchor = today(zone)
- val center = centerIndex ?: monthIndexOf(anchor)
- // Reuse the cached window unless the day changed (then it's stale for "today").
- MonthWidgetCache.data?.let { if (it.today == anchor && center in it.months) return it }
val ep = widgetEntryPoint()
- val weekStart = ep.settingsPrefs().weekStart.first().resolveFirstDay(Locale.getDefault())
- val months = (center - MONTH_WIDGET_PAD_MONTHS)..(center + MONTH_WIDGET_PAD_MONTHS)
+ val months = (centerIndex - MONTH_WIDGET_PAD_MONTHS)..(centerIndex + MONTH_WIDGET_PAD_MONTHS)
// Padded by the grid's spill into the neighbouring months.
val from = firstDayOfMonthIndex(months.first)
.minus(1, DateTimeUnit.WEEK)
@@ -184,11 +248,79 @@ internal suspend fun Context.loadMonthWidgetSource(centerIndex: Int? = null): Mo
.plus(2, DateTimeUnit.WEEK)
.atTime(23, 59, 59)
.toInstant(zone)
- val instances = ep.calendarRepository().instances(from..to).first()
return MonthWidgetSource.Ready(
- today = anchor,
- weekStart = weekStart,
- instances = instances,
+ today = today(zone),
+ weekStart = settings.weekStart.resolveFirstDay(Locale.getDefault()),
+ soften = settings.soften,
+ instances = ep.calendarRepository().instances(from..to).first().withoutCalendars(hiddenCalendars),
months = months,
- ).also { MonthWidgetCache.data = it }
+ )
+}
+
+/** Today and the app theme, for a widget that draws no events. */
+internal suspend fun Context.loadToday(): Themed =
+ Themed(today(systemZone()), widgetEntryPoint().settingsPrefs().widgetSettings.first().themeMode)
+
+/** What a widget drawing a run of days loads: those days' events, grouped like the agenda. */
+sealed interface DaysWidgetData {
+ data object NeedsPermission : DaysWidgetData
+ data class Ready(
+ val today: LocalDate,
+ val firstDay: LocalDate,
+ val lastDay: LocalDate,
+ /** Only the days with events, in order. */
+ val days: List,
+ val weekStart: DayOfWeek,
+ val is24Hour: Boolean,
+ val soften: Boolean,
+ val options: AgendaWidgetOptions,
+ val now: Instant,
+ ) : DaysWidgetData {
+ fun eventsOn(date: LocalDate): List = days.firstOrNull { it.date == date }?.events.orEmpty()
+ }
+}
+
+/** Widget [id]'s stored options and calendars, with the app's values for what it has none of. */
+internal class WidgetLoadContext(val settings: WidgetSettings, val options: AgendaWidgetOptions, val hiddenCalendars: Set)
+
+internal suspend fun Context.widgetLoadContext(id: GlanceId): WidgetLoadContext {
+ val settings = widgetEntryPoint().settingsPrefs().widgetSettings.first()
+ val state = widgetState(id)
+ return WidgetLoadContext(settings, state.agendaWidgetOptions(settings.agendaDefaults), state.hiddenCalendars)
+}
+
+/** The events on the days [days] picks from today and the week start, for [load]'s widget. */
+internal suspend fun Context.loadDays(
+ load: WidgetLoadContext,
+ days: (today: LocalDate, weekStart: DayOfWeek) -> ClosedRange,
+): Themed {
+ val settings = load.settings
+ if (!hasCalendarPermission()) return Themed(DaysWidgetData.NeedsPermission, settings.themeMode)
+ val zone = systemZone()
+ val anchor = today(zone)
+ val weekStart = settings.weekStart.resolveFirstDay(Locale.getDefault())
+ val range = days(anchor, weekStart)
+ val window = range.start.atStartOfDayIn(zone)..range.endInclusive.atTime(23, 59, 59).toInstant(zone)
+ val instances = widgetEntryPoint().calendarRepository().instances(window).first().withoutCalendars(load.hiddenCalendars)
+ return Themed(
+ DaysWidgetData.Ready(
+ today = anchor,
+ firstDay = range.start,
+ lastDay = range.endInclusive,
+ days = groupAgendaDays(range.start, range.endInclusive, instances, zone),
+ weekStart = weekStart,
+ is24Hour = settings.timeFormat.is24Hour(android.text.format.DateFormat.is24HourFormat(this)),
+ soften = settings.soften,
+ options = load.options,
+ now = Clock.System.now(),
+ ),
+ settings.themeMode,
+ )
+}
+
+/** The week [today] falls in, from [weekStart]. */
+internal fun weekOf(today: LocalDate, weekStart: DayOfWeek): ClosedRange {
+ val back = (today.dayOfWeek.ordinal - weekStart.ordinal + 7) % 7
+ val first = today.minus(back, DateTimeUnit.DAY)
+ return first..first.plus(6, DateTimeUnit.DAY)
}
diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/widget/WidgetEntryPoint.kt b/app/src/main/java/de/jeanlucmakiola/calendula/widget/WidgetEntryPoint.kt
index 177576c..e509067 100644
--- a/app/src/main/java/de/jeanlucmakiola/calendula/widget/WidgetEntryPoint.kt
+++ b/app/src/main/java/de/jeanlucmakiola/calendula/widget/WidgetEntryPoint.kt
@@ -21,6 +21,7 @@ import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
interface WidgetEntryPoint {
fun calendarRepository(): CalendarRepository
fun settingsPrefs(): SettingsPrefs
+ fun widgetRefreshCoordinator(): WidgetRefreshCoordinator
}
internal fun Context.widgetEntryPoint(): WidgetEntryPoint =
diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/widget/WidgetEventRow.kt b/app/src/main/java/de/jeanlucmakiola/calendula/widget/WidgetEventRow.kt
new file mode 100644
index 0000000..3e595d9
--- /dev/null
+++ b/app/src/main/java/de/jeanlucmakiola/calendula/widget/WidgetEventRow.kt
@@ -0,0 +1,114 @@
+package de.jeanlucmakiola.calendula.widget
+
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.unit.Dp
+import androidx.compose.ui.unit.TextUnit
+import androidx.compose.ui.unit.dp
+import androidx.glance.GlanceModifier
+import androidx.glance.GlanceTheme
+import androidx.glance.LocalContext
+import androidx.glance.action.clickable
+import androidx.glance.appwidget.action.actionStartActivity
+import androidx.glance.layout.Alignment
+import androidx.glance.layout.Column
+import androidx.glance.layout.fillMaxWidth
+import androidx.glance.layout.height
+import androidx.glance.layout.padding
+import androidx.glance.text.FontWeight
+import androidx.glance.text.Text
+import androidx.glance.text.TextStyle
+import de.jeanlucmakiola.calendula.MainActivity
+import de.jeanlucmakiola.calendula.R
+import de.jeanlucmakiola.calendula.domain.EventInstance
+import de.jeanlucmakiola.calendula.domain.isDeclined
+import de.jeanlucmakiola.calendula.ui.common.CalendarView
+import de.jeanlucmakiola.calendula.ui.common.EventDimAlpha
+import de.jeanlucmakiola.calendula.ui.common.SECONDARY_INK_ALPHA
+import de.jeanlucmakiola.calendula.ui.common.eventFill
+import de.jeanlucmakiola.calendula.ui.common.eventInk
+import de.jeanlucmakiola.calendula.widget.agenda.metricsFor
+import de.jeanlucmakiola.floret.components.Position
+import de.jeanlucmakiola.floret.glance.WidgetGroupedRow
+import de.jeanlucmakiola.floret.glance.themedColor
+
+/** Text and row sizes of a compact event list, from a widget's [WidgetSize]. */
+internal data class EventRowMetrics(val title: TextUnit, val time: TextUnit, val rowHeight: Dp, val message: TextUnit)
+
+/** The agenda widget's sizes with tighter rows, resolved against the system font scale. */
+internal fun compactRowMetrics(size: WidgetSize, fontScale: Float): EventRowMetrics {
+ val agenda = metricsFor(size)
+ return EventRowMetrics(
+ title = agenda.eventTitle,
+ time = agenda.eventTime,
+ rowHeight = agenda.contentH * fontScale + agenda.rowVPad,
+ message = agenda.message,
+ )
+}
+
+/**
+ * One event as a two-line row in its calendar's fill, part of a grouped card
+ * at [position]; opens the occurrence. [quiet] draws it in the card colour
+ * instead, for events shown only as a look ahead.
+ */
+@Composable
+internal fun WidgetEventRow(
+ event: EventInstance,
+ secondary: String,
+ soften: Boolean,
+ metrics: EventRowMetrics,
+ position: Position,
+ source: CalendarView,
+ dimmed: Boolean = false,
+ quiet: Boolean = false,
+) {
+ val context = LocalContext.current
+ fun tone(dark: Boolean): Color = eventFill(event.color, dark, soften).let { if (dimmed) it.copy(alpha = EventDimAlpha) else it }
+ val day = tone(dark = false)
+ val night = tone(dark = true)
+ // Ink is picked against the opaque fill, then carries the dim along with it.
+ fun ink(fill: Color, alpha: Float) = eventInk(fill.copy(alpha = 1f), alpha * fill.alpha)
+ val card = widgetCardColor()
+ WidgetGroupedRow(position = position, color = if (quiet) card else themedColor(day = day, night = night)) {
+ Column(
+ modifier = GlanceModifier
+ .fillMaxWidth()
+ .height(metrics.rowHeight)
+ .padding(horizontal = 12.dp)
+ .clickable(
+ actionStartActivity(
+ MainActivity.openEventIntent(
+ context = context,
+ eventId = event.eventId,
+ beginMillis = event.start.toEpochMilliseconds(),
+ endMillis = event.end.toEpochMilliseconds(),
+ source = source,
+ ),
+ ),
+ ),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ val titleInk = if (quiet) {
+ GlanceTheme.colors.onSurface
+ } else {
+ themedColor(day = ink(day, 1f), night = ink(night, 1f))
+ }
+ val timeInk = if (quiet) {
+ GlanceTheme.colors.onSurfaceVariant
+ } else {
+ themedColor(day = ink(day, SECONDARY_INK_ALPHA), night = ink(night, SECONDARY_INK_ALPHA))
+ }
+ Text(
+ text = event.title.ifBlank { context.getString(R.string.event_untitled) },
+ maxLines = 1,
+ style = TextStyle(
+ color = titleInk,
+ fontSize = metrics.title,
+ fontWeight = FontWeight.Medium,
+ textDecoration = glanceDeclinedDecoration(event.isDeclined),
+ ),
+ )
+ Text(text = secondary, maxLines = 1, style = TextStyle(color = timeInk, fontSize = metrics.time))
+ }
+ }
+}
diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/widget/WidgetKind.kt b/app/src/main/java/de/jeanlucmakiola/calendula/widget/WidgetKind.kt
new file mode 100644
index 0000000..41755f4
--- /dev/null
+++ b/app/src/main/java/de/jeanlucmakiola/calendula/widget/WidgetKind.kt
@@ -0,0 +1,120 @@
+package de.jeanlucmakiola.calendula.widget
+
+import android.appwidget.AppWidgetManager
+import android.content.Context
+import androidx.compose.ui.unit.DpSize
+import androidx.compose.ui.unit.dp
+import androidx.glance.appwidget.GlanceAppWidget
+import de.jeanlucmakiola.calendula.R
+import de.jeanlucmakiola.calendula.widget.agenda.AgendaWidget
+import de.jeanlucmakiola.calendula.widget.agenda.AgendaWidgetReceiver
+import de.jeanlucmakiola.calendula.widget.month.MonthWidget
+import de.jeanlucmakiola.calendula.widget.month.MonthWidgetReceiver
+import de.jeanlucmakiola.calendula.widget.quickadd.QuickAddWidget
+import de.jeanlucmakiola.calendula.widget.quickadd.QuickAddWidgetReceiver
+import de.jeanlucmakiola.calendula.widget.date.DateWidget
+import de.jeanlucmakiola.calendula.widget.date.DateWidgetReceiver
+import de.jeanlucmakiola.calendula.widget.week.WeekStripWidget
+import de.jeanlucmakiola.calendula.widget.week.WeekStripWidgetReceiver
+import de.jeanlucmakiola.calendula.widget.upcoming.UpcomingWidget
+import de.jeanlucmakiola.calendula.widget.upcoming.UpcomingWidgetReceiver
+import de.jeanlucmakiola.calendula.widget.today.TodayWidget
+import de.jeanlucmakiola.calendula.widget.today.TodayWidgetReceiver
+import de.jeanlucmakiola.calendula.widget.countdown.CountdownWidget
+import de.jeanlucmakiola.calendula.widget.countdown.CountdownWidgetReceiver
+import de.jeanlucmakiola.calendula.widget.split.MonthDayWidget
+import de.jeanlucmakiola.calendula.widget.split.MonthDayWidgetReceiver
+
+/** A content option a widget's config screen offers, besides background and theme. */
+enum class WidgetOption { RANGE, TEXT_SIZE, PAST_DISPLAY, SHOW_TODAY, CALENDARS }
+
+/**
+ * Every widget the app ships, told apart by their receiver: what redraws,
+ * the rollover and the config screen go through.
+ */
+enum class WidgetKind(
+ val receiver: Class<*>,
+ val label: Int,
+ val options: Set,
+ /** The size a config preview draws at before the launcher reports one. */
+ val defaultSize: DpSize,
+) {
+ AGENDA(
+ AgendaWidgetReceiver::class.java,
+ R.string.widget_agenda_label,
+ setOf(WidgetOption.RANGE, WidgetOption.TEXT_SIZE, WidgetOption.PAST_DISPLAY, WidgetOption.SHOW_TODAY, WidgetOption.CALENDARS),
+ DpSize(260.dp, 280.dp),
+ ),
+ MONTH(
+ MonthWidgetReceiver::class.java,
+ R.string.widget_month_label,
+ setOf(WidgetOption.CALENDARS),
+ DpSize(320.dp, 340.dp),
+ ),
+ QUICK_ADD(
+ QuickAddWidgetReceiver::class.java,
+ R.string.widget_quick_add_label,
+ emptySet(),
+ DpSize(76.dp, 76.dp),
+ ),
+ DATE(
+ DateWidgetReceiver::class.java,
+ R.string.widget_date_label,
+ emptySet(),
+ DpSize(76.dp, 76.dp),
+ ),
+ WEEK_STRIP(
+ WeekStripWidgetReceiver::class.java,
+ R.string.widget_week_strip_label,
+ setOf(WidgetOption.CALENDARS),
+ DpSize(300.dp, 90.dp),
+ ),
+ UPCOMING(
+ UpcomingWidgetReceiver::class.java,
+ R.string.widget_upcoming_label,
+ setOf(WidgetOption.RANGE, WidgetOption.TEXT_SIZE, WidgetOption.CALENDARS),
+ DpSize(180.dp, 180.dp),
+ ),
+ TODAY(
+ TodayWidgetReceiver::class.java,
+ R.string.widget_today_label,
+ setOf(WidgetOption.TEXT_SIZE, WidgetOption.PAST_DISPLAY, WidgetOption.CALENDARS),
+ DpSize(300.dp, 100.dp),
+ ),
+ COUNTDOWN(
+ CountdownWidgetReceiver::class.java,
+ R.string.widget_countdown_label,
+ setOf(WidgetOption.CALENDARS),
+ DpSize(200.dp, 100.dp),
+ ),
+ MONTH_DAY(
+ MonthDayWidgetReceiver::class.java,
+ R.string.widget_month_day_label,
+ setOf(WidgetOption.TEXT_SIZE, WidgetOption.PAST_DISPLAY, WidgetOption.CALENDARS),
+ DpSize(320.dp, 200.dp),
+ ),
+ ;
+
+ fun widget(): GlanceAppWidget = when (this) {
+ AGENDA -> AgendaWidget()
+ MONTH -> MonthWidget()
+ QUICK_ADD -> QuickAddWidget()
+ DATE -> DateWidget()
+ WEEK_STRIP -> WeekStripWidget()
+ UPCOMING -> UpcomingWidget()
+ TODAY -> TodayWidget()
+ COUNTDOWN -> CountdownWidget()
+ MONTH_DAY -> MonthDayWidget()
+ }
+
+ /** Whether this kind stores [AgendaWidgetOptions] of its own. */
+ val hasContentOptions: Boolean
+ get() = options.any { it != WidgetOption.CALENDARS }
+
+ companion object {
+ fun of(context: Context, appWidgetId: Int): WidgetKind? {
+ val provider = AppWidgetManager.getInstance(context)?.getAppWidgetInfo(appWidgetId)?.provider ?: return null
+ return entries.firstOrNull { it.receiver.name == provider.className }
+ }
+ }
+}
diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/widget/WidgetParts.kt b/app/src/main/java/de/jeanlucmakiola/calendula/widget/WidgetParts.kt
new file mode 100644
index 0000000..e16193b
--- /dev/null
+++ b/app/src/main/java/de/jeanlucmakiola/calendula/widget/WidgetParts.kt
@@ -0,0 +1,55 @@
+package de.jeanlucmakiola.calendula.widget
+
+import android.content.Context
+import androidx.compose.runtime.Composable
+import androidx.glance.GlanceTheme
+import androidx.glance.unit.ColorProvider
+import de.jeanlucmakiola.calendula.R
+import de.jeanlucmakiola.calendula.domain.EventInstance
+import de.jeanlucmakiola.calendula.ui.agenda.AgendaTimeLabel
+import de.jeanlucmakiola.calendula.ui.agenda.agendaTimeLabel
+import de.jeanlucmakiola.calendula.ui.common.formatTimeOfDay
+import de.jeanlucmakiola.floret.glance.LocalWidgetBackground
+import de.jeanlucmakiola.floret.glance.WidgetBackground
+import de.jeanlucmakiola.floret.locale.localizedDateFormatter
+import kotlinx.datetime.DateTimeUnit
+import kotlinx.datetime.LocalDate
+import kotlinx.datetime.plus
+import kotlinx.datetime.toJavaDayOfWeek
+import kotlinx.datetime.toJavaLocalDate
+import kotlinx.datetime.toLocalDateTime
+import java.util.Locale
+import kotlin.time.Instant
+
+/** Cards sit one tone off the root fill, and stay opaque over a wallpaper (#292). */
+@Composable
+internal fun widgetCardColor(): ColorProvider = when (LocalWidgetBackground.current) {
+ WidgetBackground.SOLID -> GlanceTheme.colors.surfaceVariant
+ WidgetBackground.TINTED, WidgetBackground.TRANSPARENT -> GlanceTheme.colors.widgetBackground
+}
+
+/** An event's time on [day]: "All day", "Starts 14:00", "Ends 9:00" or "14:00 – 15:30". */
+internal fun widgetTimeLabel(context: Context, event: EventInstance, day: LocalDate, is24Hour: Boolean): String =
+ when (val label = agendaTimeLabel(event, day, systemZone())) {
+ AgendaTimeLabel.AllDay -> context.getString(R.string.event_detail_all_day)
+ is AgendaTimeLabel.Starts -> context.getString(R.string.agenda_span_starts, formatWidgetTime(label.start, is24Hour))
+ is AgendaTimeLabel.Ends -> context.getString(R.string.agenda_span_ends, formatWidgetTime(label.end, is24Hour))
+ is AgendaTimeLabel.Range -> "${formatWidgetTime(label.start, is24Hour)} – ${formatWidgetTime(label.end, is24Hour)}"
+ }
+
+internal fun formatWidgetTime(instant: Instant, is24Hour: Boolean): String {
+ val t = instant.toLocalDateTime(systemZone()).time
+ return formatTimeOfDay(t.hour, t.minute, is24Hour, Locale.getDefault())
+}
+
+/** "Today", "Tomorrow", or the short weekday within the coming week, else the short date. */
+internal fun relativeDayLabel(context: Context, date: LocalDate, today: LocalDate): String {
+ val locale = Locale.getDefault()
+ return when {
+ date == today -> context.getString(R.string.agenda_header_today)
+ date == today.plus(1, DateTimeUnit.DAY) -> context.getString(R.string.agenda_header_tomorrow)
+ date < today.plus(7, DateTimeUnit.DAY) && date > today ->
+ date.dayOfWeek.toJavaDayOfWeek().getDisplayName(java.time.format.TextStyle.SHORT, locale)
+ else -> localizedDateFormatter(locale, "dMMM").format(date.toJavaLocalDate())
+ }
+}
diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/widget/WidgetPickerPreview.kt b/app/src/main/java/de/jeanlucmakiola/calendula/widget/WidgetPickerPreview.kt
new file mode 100644
index 0000000..b2a4788
--- /dev/null
+++ b/app/src/main/java/de/jeanlucmakiola/calendula/widget/WidgetPickerPreview.kt
@@ -0,0 +1,177 @@
+package de.jeanlucmakiola.calendula.widget
+
+import android.content.Context
+import androidx.glance.appwidget.GlanceAppWidget
+import androidx.glance.appwidget.SizeMode
+import androidx.glance.appwidget.provideContent
+import de.jeanlucmakiola.calendula.R
+import de.jeanlucmakiola.calendula.data.prefs.is24Hour
+import de.jeanlucmakiola.calendula.data.prefs.resolveFirstDay
+import de.jeanlucmakiola.calendula.domain.EventInstance
+import de.jeanlucmakiola.calendula.ui.agenda.groupAgendaDays
+import de.jeanlucmakiola.calendula.widget.agenda.AgendaWidgetContent
+import de.jeanlucmakiola.calendula.widget.agenda.AgendaWidgetReceiver
+import de.jeanlucmakiola.calendula.widget.countdown.CountdownData
+import de.jeanlucmakiola.calendula.widget.countdown.CountdownOptions
+import de.jeanlucmakiola.calendula.widget.countdown.CountdownWidgetContent
+import de.jeanlucmakiola.calendula.widget.countdown.CountdownWidgetReceiver
+import de.jeanlucmakiola.calendula.widget.date.DateWidgetContent
+import de.jeanlucmakiola.calendula.widget.date.DateWidgetReceiver
+import de.jeanlucmakiola.calendula.widget.month.MonthWidgetContent
+import de.jeanlucmakiola.calendula.widget.month.MonthWidgetReceiver
+import de.jeanlucmakiola.calendula.widget.quickadd.QuickAddWidgetContent
+import de.jeanlucmakiola.calendula.widget.quickadd.QuickAddWidgetReceiver
+import de.jeanlucmakiola.calendula.widget.split.MonthDayData
+import de.jeanlucmakiola.calendula.widget.split.MonthDayWidgetContent
+import de.jeanlucmakiola.calendula.widget.split.MonthDayWidgetReceiver
+import de.jeanlucmakiola.calendula.widget.today.TodayWidgetContent
+import de.jeanlucmakiola.calendula.widget.today.TodayWidgetReceiver
+import de.jeanlucmakiola.calendula.widget.upcoming.UpcomingWidgetContent
+import de.jeanlucmakiola.calendula.widget.upcoming.UpcomingWidgetReceiver
+import de.jeanlucmakiola.calendula.widget.week.WeekStripWidgetContent
+import de.jeanlucmakiola.calendula.widget.week.WeekStripWidgetReceiver
+import de.jeanlucmakiola.floret.glance.publishWidgetPreviews
+import kotlinx.coroutines.flow.first
+import kotlinx.datetime.DateTimeUnit
+import kotlinx.datetime.LocalDate
+import kotlinx.datetime.LocalTime
+import kotlinx.datetime.TimeZone
+import kotlinx.datetime.atStartOfDayIn
+import kotlinx.datetime.atTime
+import kotlinx.datetime.plus
+import kotlinx.datetime.toInstant
+import java.util.Locale
+import kotlin.time.Duration.Companion.hours
+import kotlin.time.Duration.Companion.minutes
+
+/** Republishes the generated picker previews (Android 15+) after an app update or a language change. */
+internal suspend fun publishCalendulaWidgetPreviews(context: Context) {
+ publishWidgetPreviews(
+ context,
+ version = "${context.packageManager.getPackageInfo(context.packageName, 0).lastUpdateTime}-${Locale.getDefault().toLanguageTag()}",
+ receivers = listOf(
+ AgendaWidgetReceiver::class,
+ MonthWidgetReceiver::class,
+ QuickAddWidgetReceiver::class,
+ DateWidgetReceiver::class,
+ WeekStripWidgetReceiver::class,
+ UpcomingWidgetReceiver::class,
+ TodayWidgetReceiver::class,
+ CountdownWidgetReceiver::class,
+ MonthDayWidgetReceiver::class,
+ ),
+ )
+}
+
+/** The size [kind]'s picker preview is laid out at. */
+internal fun previewSizeOf(kind: WidgetKind) = SizeMode.Responsive(setOf(kind.defaultSize))
+
+/** [kind]'s picker preview: the real widget, drawn with sample events around today's morning. */
+internal suspend fun GlanceAppWidget.provideSamplePreview(context: Context, kind: WidgetKind) {
+ val settings = context.widgetEntryPoint().settingsPrefs().widgetSettings.first()
+ val zone = systemZone()
+ val today = today(zone)
+ val now = today.atTime(8, 0).toInstant(zone)
+ val instances = context.sampleInstances(today, zone)
+ val weekStart = settings.weekStart.resolveFirstDay(Locale.getDefault())
+ val is24Hour = settings.timeFormat.is24Hour(android.text.format.DateFormat.is24HourFormat(context))
+ val options = settings.agendaDefaults
+ val appearance = WidgetAppearance()
+ val index = monthIndexOf(today)
+ val month = MonthWidgetSource.Ready(today, weekStart, settings.soften, instances, index..index)
+ fun days(range: ClosedRange) = DaysWidgetData.Ready(
+ today = today,
+ firstDay = range.start,
+ lastDay = range.endInclusive,
+ days = groupAgendaDays(range.start, range.endInclusive, instances, zone),
+ weekStart = weekStart,
+ is24Hour = is24Hour,
+ soften = settings.soften,
+ options = options,
+ now = now,
+ )
+ val theme = settings.themeMode
+ provideContent {
+ when (kind) {
+ WidgetKind.AGENDA -> AgendaWidgetContent(
+ AgendaWidgetData.Ready(
+ today = today,
+ days = groupAgendaDays(today, today.plus(6, DateTimeUnit.DAY), instances, zone),
+ is24Hour = is24Hour,
+ soften = settings.soften,
+ pastDisplay = options.pastDisplay,
+ showToday = options.showToday,
+ textSize = options.textSize,
+ now = now,
+ ),
+ appearance,
+ theme,
+ preview = true,
+ )
+ WidgetKind.MONTH -> MonthWidgetContent(month, index, appearance, theme)
+ WidgetKind.MONTH_DAY -> MonthDayWidgetContent(
+ MonthDayData.Ready(month, options, is24Hour, now),
+ index,
+ selected = null,
+ appearance = appearance,
+ appTheme = theme,
+ preview = true,
+ )
+ WidgetKind.QUICK_ADD -> QuickAddWidgetContent(today, appearance, theme)
+ WidgetKind.DATE -> DateWidgetContent(today, appearance, theme)
+ WidgetKind.WEEK_STRIP -> WeekStripWidgetContent(days(weekOf(today, weekStart)), appearance, theme)
+ WidgetKind.UPCOMING -> UpcomingWidgetContent(days(today..today.plus(6, DateTimeUnit.DAY)), appearance, theme)
+ WidgetKind.TODAY -> TodayWidgetContent(days(today..today.plus(1, DateTimeUnit.DAY)), appearance, theme, preview = true)
+ WidgetKind.COUNTDOWN -> CountdownWidgetContent(
+ CountdownData.Ready(
+ options = CountdownOptions(),
+ event = instances.first { !it.isAllDay && it.start > now },
+ now = now,
+ today = today,
+ is24Hour = is24Hour,
+ soften = settings.soften,
+ appWidgetId = null,
+ ),
+ appearance,
+ theme,
+ )
+ }
+ }
+}
+
+/** A plausible few weeks: a busy today, a trip tomorrow, and a scatter of days with something on. */
+private fun Context.sampleInstances(today: LocalDate, zone: TimeZone): List {
+ var id = 0L
+ fun timed(day: Int, at: LocalTime, length: kotlin.time.Duration, title: Int, color: Int): EventInstance {
+ val start = today.plus(day, DateTimeUnit.DAY).atTime(at).toInstant(zone)
+ id++
+ return EventInstance(id, id, 1, getString(title), start, start + length, false, color, null)
+ }
+ fun allDay(day: Int, title: Int, color: Int): EventInstance {
+ val date = today.plus(day, DateTimeUnit.DAY)
+ id++
+ return EventInstance(
+ id, id, 1, getString(title),
+ date.atStartOfDayIn(TimeZone.UTC), date.plus(1, DateTimeUnit.DAY).atStartOfDayIn(TimeZone.UTC),
+ true, color, null,
+ )
+ }
+ return listOf(
+ timed(0, LocalTime(9, 30), 30.minutes, R.string.settings_past_events_sample_morning, Blue),
+ timed(0, LocalTime(12, 30), 1.hours, R.string.settings_past_events_sample_midday, Green),
+ timed(0, LocalTime(19, 0), 2.hours, R.string.settings_past_events_sample_evening, Purple),
+ allDay(1, R.string.widget_preview_sample_trip, Orange),
+ timed(2, LocalTime(10, 0), 1.hours, R.string.widget_preview_sample_dentist, Red),
+ timed(-6, LocalTime(9, 30), 30.minutes, R.string.settings_past_events_sample_morning, Blue),
+ timed(-2, LocalTime(19, 0), 2.hours, R.string.settings_past_events_sample_evening, Purple),
+ timed(5, LocalTime(19, 0), 2.hours, R.string.settings_past_events_sample_evening, Purple),
+ timed(8, LocalTime(9, 30), 30.minutes, R.string.settings_past_events_sample_morning, Blue),
+ timed(12, LocalTime(12, 30), 1.hours, R.string.settings_past_events_sample_midday, Green),
+ ).sortedBy { it.start }
+}
+
+private const val Blue = 0xFF039BE5.toInt()
+private const val Green = 0xFF33B679.toInt()
+private const val Purple = 0xFF8E24AA.toInt()
+private const val Orange = 0xFFF4511E.toInt()
+private const val Red = 0xFFD50000.toInt()
diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/widget/WidgetRedraw.kt b/app/src/main/java/de/jeanlucmakiola/calendula/widget/WidgetRedraw.kt
new file mode 100644
index 0000000..ec8bd90
--- /dev/null
+++ b/app/src/main/java/de/jeanlucmakiola/calendula/widget/WidgetRedraw.kt
@@ -0,0 +1,124 @@
+package de.jeanlucmakiola.calendula.widget
+
+import android.app.PendingIntent
+import android.appwidget.AppWidgetManager
+import android.content.ComponentName
+import android.content.Context
+import android.content.Intent
+import android.os.SystemClock
+import de.jeanlucmakiola.calendula.data.calendar.CalendarRepository
+import de.jeanlucmakiola.calendula.data.di.ApplicationScope
+import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
+import de.jeanlucmakiola.floret.glance.WidgetRefresher
+import de.jeanlucmakiola.floret.glance.WidgetRollover
+import de.jeanlucmakiola.floret.glance.updatePlaced
+import de.jeanlucmakiola.floret.glance.updateWithRefresh
+import androidx.glance.appwidget.GlanceAppWidgetManager
+import dagger.hilt.android.qualifiers.ApplicationContext
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.CoroutineStart
+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> = WidgetKind.entries.map { it.receiver }
+
+/** 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 every widget keys its data load on it (#420).
+ */
+internal suspend fun redrawWidgets(context: Context) {
+ WidgetKind.entries.forEach { redrawWidgets(context, it) }
+}
+
+internal suspend fun redrawWidgets(context: Context, kind: WidgetKind) =
+ kind.widget().updatePlaced(context, kind.receiver, bumpRefreshStamp = true)
+
+/** Redraws only the widgets with no content options of their own, which still follow the app's. */
+private suspend fun redrawUnconfiguredWidgets(context: Context) {
+ val appContext = context.applicationContext
+ val manager = AppWidgetManager.getInstance(appContext) ?: return
+ val glanceManager = GlanceAppWidgetManager(appContext)
+ WidgetKind.entries.filter { it.hasContentOptions }.forEach { kind ->
+ manager.getAppWidgetIds(ComponentName(appContext, kind.receiver)).forEach { appWidgetId ->
+ val glanceId = glanceManager.getGlanceIdBy(appWidgetId)
+ if (!appContext.widgetState(glanceId).hasAgendaOptions) kind.widget().updateWithRefresh(appContext, glanceId)
+ }
+ }
+}
+
+/** 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. The app's agenda options only redraw
+ * widgets that have no content options of their own (#292). 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 all = WidgetKind.entries.map { kind -> WidgetRefresher(scope) { redrawWidgets(context, kind) } }
+ val unconfigured = WidgetRefresher(scope) { redrawUnconfiguredWidgets(context) }
+ watch(repository.localChanges) {
+ lastLocalChange.set(SystemClock.elapsedRealtime())
+ all.forEach { it.request() }
+ }
+ watch(prefs.widgetSettings.map { it.shared }.distinctUntilChanged().drop(1)) {
+ all.forEach { it.request() }
+ }
+ watch(prefs.widgetSettings.map { it.agendaDefaults }.distinctUntilChanged().drop(1)) {
+ unconfigured.request()
+ }
+ }
+
+ private fun watch(changes: Flow<*>, onChange: () -> Unit) {
+ scope.launch(Dispatchers.IO, start = CoroutineStart.UNDISPATCHED) {
+ changes.collect { onChange() }
+ }
+ }
+}
diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/widget/WidgetRolloverScheduler.kt b/app/src/main/java/de/jeanlucmakiola/calendula/widget/WidgetRolloverScheduler.kt
deleted file mode 100644
index b08c987..0000000
--- a/app/src/main/java/de/jeanlucmakiola/calendula/widget/WidgetRolloverScheduler.kt
+++ /dev/null
@@ -1,115 +0,0 @@
-package de.jeanlucmakiola.calendula.widget
-
-import android.app.AlarmManager
-import android.app.PendingIntent
-import android.appwidget.AppWidgetManager
-import android.content.ComponentName
-import android.content.Context
-import android.content.Intent
-import androidx.core.content.getSystemService
-import de.jeanlucmakiola.calendula.widget.agenda.AgendaWidgetReceiver
-import de.jeanlucmakiola.calendula.widget.month.MonthWidgetReceiver
-import kotlinx.datetime.DateTimeUnit
-import kotlinx.datetime.TimeZone
-import kotlinx.datetime.atStartOfDayIn
-import kotlinx.datetime.plus
-import kotlinx.datetime.toLocalDateTime
-import kotlin.time.Clock
-import kotlin.time.Duration.Companion.hours
-import kotlin.time.Duration.Companion.seconds
-import kotlin.time.Instant
-
-/**
- * Holds the app's own wake-up for the next local midnight, so the home-screen
- * widgets roll "today" over on the day boundary (#228).
- *
- * The widgets used to lean on `ACTION_DATE_CHANGED`, which is not an exempted
- * implicit broadcast — a manifest-declared receiver has not been given it since
- * Android 8, leaving only the throttled `updatePeriodMillis`.
- *
- * Exactly one alarm exists at a time and every firing re-arms the next, the same
- * shape as [de.jeanlucmakiola.calendula.data.reminders.ReminderAlarmScheduler].
- * Deliberately **inexact**: `setAndAllowWhileIdle` needs no permission and
- * survives doze (plain `set` does not), a rollover a few minutes late is
- * invisible on a sleeping screen, and exact alarms stay reserved for snooze.
- */
-object WidgetRolloverScheduler {
-
- /**
- * Fire just *after* midnight: an alarm delivered a few milliseconds early
- * would still read the old date and re-arm for an instant later.
- */
- internal val ROLLOVER_SLACK = 5.seconds
-
- /**
- * Arm the next rollover, or cancel a pending one when no widget is placed.
- * Idempotent, so every trigger (boot, app start, widget added/removed, the
- * rollover itself, a clock or timezone change) can just call it.
- */
- fun sync(context: Context) {
- val appContext = context.applicationContext
- val alarmManager = appContext.getSystemService() ?: return
- val pendingIntent = rolloverPendingIntent(appContext)
- if (!hasPlacedWidgets(appContext)) {
- alarmManager.cancel(pendingIntent)
- return
- }
- val triggerAt = nextRolloverAt(Clock.System.now(), TimeZone.currentSystemDefault())
- alarmManager.setAndAllowWhileIdle(
- AlarmManager.RTC_WAKEUP, triggerAt.toEpochMilliseconds(), pendingIntent,
- )
- }
-
- /**
- * The instant just after the next local midnight following [now] in [zone].
- *
- * The *actual* start of day, not 00:00, so it holds where a DST jump means
- * midnight never happens (Havana) and where a date-line move skips a whole
- * local date (Apia, December 2011) — the loop walks on to the next real day.
- *
- * The mirror case — a zone rewinding *across* midnight, so the day starts
- * twice — resolves to the earlier start and runs an hour ahead of the clock.
- * No live tz entry does that (Brazil dropped DST in 2019) and
- * `updatePeriodMillis` covers it, so it isn't worth state to detect.
- */
- fun nextRolloverAt(now: Instant, zone: TimeZone): Instant {
- val date = now.toLocalDateTime(zone).date
- var days = 1
- while (days <= MAX_LOOKAHEAD_DAYS) {
- val candidate = date.plus(days, DateTimeUnit.DAY).atStartOfDayIn(zone) + ROLLOVER_SLACK
- if (candidate > now) return candidate
- days++
- }
- // Unreachable for any zone in the tz database. Deliberately an hour and
- // not the slack: a 5-second retry would just hit this branch again and
- // wake the device in a loop.
- return now + 1.hours
- }
-
- private fun hasPlacedWidgets(context: Context): Boolean {
- val manager = AppWidgetManager.getInstance(context) ?: return false
- return PROVIDERS.any {
- manager.getAppWidgetIds(ComponentName(context, it)).isNotEmpty()
- }
- }
-
- private fun rolloverPendingIntent(context: Context): PendingIntent =
- PendingIntent.getBroadcast(
- context,
- ROLLOVER_REQUEST_CODE,
- Intent(context, WidgetUpdateReceiver::class.java)
- .setAction(WidgetUpdateReceiver.ACTION_ROLLOVER),
- PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
- )
-
- private val PROVIDERS = listOf(
- MonthWidgetReceiver::class.java,
- AgendaWidgetReceiver::class.java,
- )
-
- /** Fixed: there is only ever one rollover alarm, and re-arming must replace it. */
- private const val ROLLOVER_REQUEST_CODE = 0x0DA1
-
- /** A gap of more than a couple of days does not exist in any tz database entry. */
- private const val MAX_LOOKAHEAD_DAYS = 3
-}
diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/widget/WidgetTheme.kt b/app/src/main/java/de/jeanlucmakiola/calendula/widget/WidgetTheme.kt
index d679fe5..8809be4 100644
--- a/app/src/main/java/de/jeanlucmakiola/calendula/widget/WidgetTheme.kt
+++ b/app/src/main/java/de/jeanlucmakiola/calendula/widget/WidgetTheme.kt
@@ -1,39 +1,36 @@
package de.jeanlucmakiola.calendula.widget
-import android.os.Build
import androidx.compose.runtime.Composable
-import androidx.glance.GlanceTheme
-import androidx.glance.material3.ColorProviders
import androidx.glance.text.TextDecoration
import de.jeanlucmakiola.calendula.ui.theme.CalendulaDarkFallback
import de.jeanlucmakiola.calendula.ui.theme.CalendulaLightFallback
+import de.jeanlucmakiola.floret.glance.FloretGlanceTheme
+import de.jeanlucmakiola.floret.glance.WidgetSurface
+import de.jeanlucmakiola.floret.glance.WidgetThemeMode
+import de.jeanlucmakiola.floret.prefs.ThemeMode
/**
- * Brand fallback for devices without Material You dynamic colour (API < 31).
- * Reuses the exact same hand-tuned schemes as the in-app theme
- * ([CalendulaLightFallback] / [CalendulaDarkFallback]) so a widget on an older
- * device matches the app surface-for-surface.
- */
-private val CalendulaGlanceColors = ColorProviders(
- light = CalendulaLightFallback,
- dark = CalendulaDarkFallback,
-)
-
-/**
- * Glance equivalent of `CalendulaTheme`. On API 31+ it follows the system's
- * Material You palette (so the widget matches the home screen / the app's
- * dynamic colour); below that it falls back to the brand scheme. Either way the
- * widget draws only from M3 colour-role tokens (`GlanceTheme.colors.*`) — never
- * a hardcoded colour — so it tracks light/dark automatically.
+ * Glance equivalent of `CalendulaTheme`: Material You on API 31+, the app's
+ * hand-tuned fallback schemes below that, in the light/dark [mode] the widget
+ * was configured with (#292).
*/
@Composable
-fun CalendulaGlanceTheme(content: @Composable () -> Unit) {
- val colors = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
- GlanceTheme.colors
- } else {
- CalendulaGlanceColors
+fun CalendulaGlanceTheme(mode: WidgetThemeMode, content: @Composable () -> Unit) {
+ FloretGlanceTheme(
+ lightScheme = CalendulaLightFallback,
+ darkScheme = CalendulaDarkFallback,
+ dynamicColor = true,
+ mode = mode,
+ content = content,
+ )
+}
+
+/** A widget's root: its configured theme and background around [content]. */
+@Composable
+internal fun CalendulaWidgetRoot(appearance: WidgetAppearance, appTheme: ThemeMode, content: @Composable () -> Unit) {
+ CalendulaGlanceTheme(appearance.theme.resolve(appTheme)) {
+ WidgetSurface(background = appearance.background, content = content)
}
- GlanceTheme(colors = colors, content = content)
}
/**
diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/widget/WidgetUpdateReceiver.kt b/app/src/main/java/de/jeanlucmakiola/calendula/widget/WidgetUpdateReceiver.kt
index b4972aa..45ddeb1 100644
--- a/app/src/main/java/de/jeanlucmakiola/calendula/widget/WidgetUpdateReceiver.kt
+++ b/app/src/main/java/de/jeanlucmakiola/calendula/widget/WidgetUpdateReceiver.kt
@@ -1,120 +1,29 @@
package de.jeanlucmakiola.calendula.widget
-import android.content.BroadcastReceiver
+import android.app.PendingIntent
import android.content.Context
import android.content.Intent
-import android.os.SystemClock
-import androidx.glance.appwidget.updateAll
-import de.jeanlucmakiola.calendula.widget.agenda.AgendaWidget
-import de.jeanlucmakiola.calendula.widget.month.MonthWidget
-import kotlinx.coroutines.CoroutineScope
-import kotlinx.coroutines.Dispatchers
-import kotlinx.coroutines.SupervisorJob
-import kotlinx.coroutines.launch
-import java.util.concurrent.atomic.AtomicLong
+import de.jeanlucmakiola.floret.glance.FloretWidgetUpdateReceiver
+import kotlin.time.Duration.Companion.minutes
/**
- * Redraws both home-screen widgets when their data goes stale, and keeps the
- * midnight rollover alarm armed. Triggered by:
- * - `PROVIDER_CHANGED` from the calendar provider — fires on any data change,
- * so it covers both the app's own writes and external sync.
- * - [ACTION_ROLLOVER], the app's own alarm from [WidgetRolloverScheduler] —
- * the day boundary, so "today" highlighting and the agenda's past-event
- * dimming move on (#228).
- * - `TIME_SET` / `TIMEZONE_CHANGED` — the day boundary moved, so redraw *and*
- * re-arm.
- * - `BOOT_COMPLETED` / `MY_PACKAGE_REPLACED` — both wipe pending alarms; the
- * latter is also what arms installs upgrading into the fix.
- *
- * `DATE_CHANGED` rides along as a free extra the system may or may not send —
- * see [WidgetRolloverScheduler]. The backstops are `updatePeriodMillis` in the
- * provider XML and the month widget's refresh button.
- *
- * Every action that dropped, consumed or invalidated the alarm re-arms it, and
- * `PROVIDER_CHANGED` re-arms at most once every [DATA_REARM_INTERVAL_MILLIS]
- * ([dataRearmDue]). The alarm is inexact, so the system may drop it outright in
- * a restricted standby bucket; nothing in ordinary daily use would then notice,
- * and the widget sits on yesterday's date. A calendar change is the one signal
- * that keeps arriving — but it arrives on *every* write (each drag, each event
- * of a thousand-event import), which is what the interval is for.
- *
- * Exported for the system broadcasts; an extra redraw from another app is
- * harmless.
+ * Redraws both widgets when their data, day or language goes stale, and keeps
+ * the midnight rollover armed. `PROVIDER_CHANGED` covers changes made outside
+ * the app; the app's own writes go through [WidgetRefreshCoordinator]. A data
+ * change also re-arms, at most every 15 minutes: the inexact alarm can be
+ * dropped in a restricted standby bucket, and a calendar change is the one
+ * signal that keeps arriving. A `PROVIDER_CHANGED` right after one of the
+ * app's own changes only echoes it, so it doesn't redraw a second time.
*/
-class WidgetUpdateReceiver : BroadcastReceiver() {
- override fun onReceive(context: Context, intent: Intent) {
- // Exported, so anything can reach it with an explicit intent. Nothing
- // here crosses a trust boundary, but narrowing to the actions we asked
- // for keeps a stray broadcast from costing two wide provider reads.
- if (intent.action !in HANDLED_ACTIONS) return
- val appContext = context.applicationContext
- val rearm = intent.action in REARM_ACTIONS ||
- (intent.action == Intent.ACTION_PROVIDER_CHANGED && dataRearmDue())
- // The host sends APPWIDGET_UPDATE after the re-arm-only ones anyway, so
- // redrawing there would only repeat the work in a cold process, at the
- // moment the device is most contended.
- val redraw = intent.action !in REARM_ONLY_ACTIONS
- val pending = goAsync()
- // Calendar data may have changed (sync / our own write) — drop the cached
- // month window so the widgets reload fresh. Month paging does NOT call
- // this, so arrow taps stay instant.
- if (redraw) invalidateMonthWidgetCache()
- CoroutineScope(SupervisorJob() + Dispatchers.IO).launch {
- try {
- // Re-armed first, so the next day boundary is covered whatever
- // the redraw does — and off the receiving thread, since it is a
- // handful of binder calls (`CalendulaApp` moves it for the same
- // reason).
- if (rearm) WidgetRolloverScheduler.sync(appContext)
- if (redraw) {
- AgendaWidget().updateAll(appContext)
- MonthWidget().updateAll(appContext)
- }
- } finally {
- pending.finish()
- }
- }
- }
+class WidgetUpdateReceiver : FloretWidgetUpdateReceiver() {
+ override val rolloverAction = ACTION_WIDGET_ROLLOVER
+ override val widgetReceiverClasses = WIDGET_RECEIVER_CLASSES
+ override val extraHandledActions = setOf(Intent.ACTION_PROVIDER_CHANGED)
+ override val extraRearmInterval = 15.minutes
- companion object {
- /** The app's own midnight wake-up; see [WidgetRolloverScheduler]. */
- const val ACTION_ROLLOVER = "de.jeanlucmakiola.calendula.widget.ROLLOVER"
+ override fun rolloverPendingIntent(context: Context): PendingIntent = widgetRolloverPendingIntent(context)
- /** Both wipe pending alarms, and the host redraws the widgets itself after them. */
- private val REARM_ONLY_ACTIONS = setOf(
- Intent.ACTION_BOOT_COMPLETED,
- Intent.ACTION_MY_PACKAGE_REPLACED,
- )
+ override fun skipRedraw(action: String) = action == Intent.ACTION_PROVIDER_CHANGED && isProviderEcho()
- /**
- * The actions that leave no alarm standing: the two above wipe it, the
- * rollover consumed itself, and a clock, zone or day change moved the
- * boundary the pending one was set for.
- */
- internal val REARM_ACTIONS = REARM_ONLY_ACTIONS + setOf(
- ACTION_ROLLOVER,
- Intent.ACTION_TIME_CHANGED,
- Intent.ACTION_TIMEZONE_CHANGED,
- Intent.ACTION_DATE_CHANGED,
- )
-
- internal val HANDLED_ACTIONS = REARM_ACTIONS + Intent.ACTION_PROVIDER_CHANGED
-
- /** How rarely a calendar change may re-arm; see the class doc. */
- private const val DATA_REARM_INTERVAL_MILLIS = 15 * 60_000L
-
- /** Uptime of the last data-change re-arm, 0 for none in this process. */
- private val lastDataRearmMillis = AtomicLong(0L)
-
- /**
- * Whether a calendar change may re-arm now — the backstop for an alarm
- * the system dropped, throttled so a burst of writes costs one pass.
- */
- private fun dataRearmDue(): Boolean {
- val now = SystemClock.elapsedRealtime()
- val last = lastDataRearmMillis.get()
- val due = last == 0L || now - last >= DATA_REARM_INTERVAL_MILLIS
- return due && lastDataRearmMillis.compareAndSet(last, now)
- }
- }
+ override suspend fun redraw(context: Context) = redrawWidgets(context)
}
diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/widget/agenda/AgendaScale.kt b/app/src/main/java/de/jeanlucmakiola/calendula/widget/agenda/AgendaScale.kt
index 3414741..8b07067 100644
--- a/app/src/main/java/de/jeanlucmakiola/calendula/widget/agenda/AgendaScale.kt
+++ b/app/src/main/java/de/jeanlucmakiola/calendula/widget/agenda/AgendaScale.kt
@@ -6,17 +6,11 @@ import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import de.jeanlucmakiola.calendula.widget.WidgetSize
-/**
- * Horizontal layout constants for an agenda event row. These don't scale with the
- * size step, since a wider stripe or gap would eat title width, but [TEXT_INDENT]
- * is derived from them so non-event text can't drift out of alignment.
- */
-internal val ROW_H_PAD = 4.dp
-internal val STRIPE_WIDTH = 5.dp
-internal val STRIPE_GAP = 10.dp
+/** Horizontal inset of a row's text inside its card; doesn't scale, so it never eats title width. */
+internal val ROW_H_PAD = 12.dp
-/** Left indent that lines non-event text up with the event title column. */
-internal val TEXT_INDENT = ROW_H_PAD + STRIPE_WIDTH + STRIPE_GAP
+/** Space between the date-pill gutter and a day's card. */
+internal val GUTTER_GAP = 6.dp
/**
* Every size the agenda widget varies by [WidgetSize]. Values that genuinely
@@ -24,90 +18,91 @@ internal val TEXT_INDENT = ROW_H_PAD + STRIPE_WIDTH + STRIPE_GAP
* constants rather than routing through here.
*/
internal data class AgendaMetrics(
- val title: TextUnit, // header "Upcoming"
- val dayHeader: TextUnit, // day label ("Today · …")
+ val pillWeekday: TextUnit, // date pill weekday ("Thu")
+ val pillDay: TextUnit, // date pill day number
val eventTitle: TextUnit, // event title line
val eventTime: TextUnit, // time/location line
val placeholder: TextUnit, // "no more events today" (#35)
val message: TextUnit, // empty/permission centred message
- val stripeH: Dp, // coloured event stripe height
- val iconImage: Dp, // header action icon glyph
- val iconBox: Dp, // header action touch target
+ val contentH: Dp, // an event row's two text lines
+ val pillWidth: Dp, // date pill / gutter width
val rowVPad: Dp, // event row vertical padding
- val dayHeaderTopPad: Dp, // space above a day header
+ val dayGap: Dp, // space between two days' cards
+ val monthLabel: TextUnit, // month name above a new month's first day
) {
+ /** One event row's height, shared by the date pill beside it and the placeholder row. */
+ val rowHeight: Dp get() = contentH + rowVPad * 2
+
/**
- * Resolves the stripe height against the user's system font scale: the stripe
- * is a [Dp] while the lines beside it are `sp`, so without this the text
- * outgrows it at large font settings.
+ * Resolves the row content height and pill width against the user's system font
+ * scale: both are [Dp] while the text in and beside them is `sp`, so without
+ * this the text outgrows them at large font settings.
*/
fun scaledForFont(fontScale: Float): AgendaMetrics =
- if (fontScale == 1f) this else copy(stripeH = stripeH * fontScale)
+ if (fontScale == 1f) this else copy(contentH = contentH * fontScale, pillWidth = pillWidth * fontScale)
}
/*
- * Type sizes are anchored to the Material 3 type scale, with two deviations:
+ * Type sizes are anchored to the Material 3 type scale, with one deviation:
*
- * ‡ SMALL's 13sp day header is off-scale, held there so SMALL reproduces the
- * widget's original constants verbatim (#51).
* † Above Title Medium the M3 scale jumps 16 → 22 → 24, too coarse for four
* steps; interpolated values are used where a role would force a ≥1.4x jump.
*/
private val SMALL_METRICS = AgendaMetrics(
- title = 16.sp, // M3 Title Medium
- dayHeader = 13.sp, // ‡ off-scale, holds the #51 baseline
+ pillWeekday = 11.sp,
+ pillDay = 16.sp,
eventTitle = 14.sp, // M3 Body Medium
eventTime = 12.sp, // M3 Body Small
placeholder = 14.sp, // M3 Body Medium
message = 14.sp, // M3 Body Medium
- stripeH = 36.dp,
- iconImage = 22.dp,
- iconBox = 40.dp,
+ contentH = 36.dp,
+ pillWidth = 40.dp,
rowVPad = 4.dp,
- dayHeaderTopPad = 10.dp,
+ dayGap = 8.dp,
+ monthLabel = 12.sp,
)
private val MEDIUM_METRICS = AgendaMetrics(
- title = 18.sp, // †
- dayHeader = 14.sp, // M3 Title Small
+ pillWeekday = 12.sp,
+ pillDay = 18.sp,
eventTitle = 16.sp, // M3 Body Large
eventTime = 14.sp, // M3 Body Medium
placeholder = 16.sp, // M3 Body Large
message = 16.sp, // M3 Body Large
- stripeH = 40.dp,
- iconImage = 24.dp,
- iconBox = 44.dp,
+ contentH = 40.dp,
+ pillWidth = 44.dp,
rowVPad = 5.dp,
- dayHeaderTopPad = 11.dp,
+ dayGap = 9.dp,
+ monthLabel = 14.sp,
)
private val LARGE_METRICS = AgendaMetrics(
- title = 20.sp, // †
- dayHeader = 16.sp, // M3 Title Medium
+ pillWeekday = 12.sp,
+ pillDay = 20.sp,
eventTitle = 18.sp, // †
eventTime = 14.sp, // M3 Body Medium — the secondary line steps more slowly
placeholder = 18.sp, // † on purpose, so the title keeps its
message = 18.sp, // † lead and the hierarchy survives.
- stripeH = 46.dp,
- iconImage = 26.dp,
- iconBox = 48.dp,
+ contentH = 46.dp,
+ pillWidth = 48.dp,
rowVPad = 6.dp,
- dayHeaderTopPad = 12.dp,
+ dayGap = 10.dp,
+ monthLabel = 14.sp,
)
private val EXTRA_LARGE_METRICS = AgendaMetrics(
- title = 22.sp, // M3 Title Large
- dayHeader = 18.sp, // †
+ pillWeekday = 14.sp,
+ pillDay = 22.sp,
eventTitle = 20.sp, // †
eventTime = 16.sp, // M3 Body Large
placeholder = 20.sp, // †
message = 20.sp, // †
- stripeH = 52.dp,
- iconImage = 28.dp,
- iconBox = 52.dp,
+ contentH = 52.dp,
+ pillWidth = 52.dp,
rowVPad = 8.dp,
- dayHeaderTopPad = 14.dp,
+ dayGap = 12.dp,
+ monthLabel = 16.sp,
)
/** Indexed by [WidgetSize.ordinal] so lookup allocates nothing per recomposition. */
diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/widget/agenda/AgendaWidget.kt b/app/src/main/java/de/jeanlucmakiola/calendula/widget/agenda/AgendaWidget.kt
index 548572c..7afaf48 100644
--- a/app/src/main/java/de/jeanlucmakiola/calendula/widget/agenda/AgendaWidget.kt
+++ b/app/src/main/java/de/jeanlucmakiola/calendula/widget/agenda/AgendaWidget.kt
@@ -1,40 +1,27 @@
package de.jeanlucmakiola.calendula.widget.agenda
+import de.jeanlucmakiola.calendula.widget.WidgetKind
+import de.jeanlucmakiola.calendula.widget.previewSizeOf
+import de.jeanlucmakiola.calendula.widget.provideSamplePreview
import de.jeanlucmakiola.calendula.domain.isDeclined
import de.jeanlucmakiola.calendula.widget.glanceDeclinedDecoration
import android.content.Context
-import android.content.res.Configuration
import androidx.compose.runtime.Composable
-import androidx.compose.runtime.LaunchedEffect
-import androidx.compose.runtime.getValue
-import androidx.compose.runtime.mutableStateOf
-import androidx.compose.runtime.remember
-import androidx.compose.runtime.setValue
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp
-import androidx.datastore.preferences.core.Preferences
-import androidx.datastore.preferences.core.booleanPreferencesKey
-import androidx.datastore.preferences.core.stringPreferencesKey
-import androidx.glance.ColorFilter
import androidx.glance.GlanceId
import androidx.glance.GlanceModifier
import androidx.glance.GlanceTheme
-import androidx.glance.Image
import androidx.glance.ImageProvider
-import androidx.glance.action.ActionParameters
import androidx.glance.action.clickable
import androidx.glance.appwidget.GlanceAppWidget
import androidx.glance.appwidget.SizeMode
-import androidx.glance.appwidget.action.ActionCallback
-import androidx.glance.appwidget.action.actionRunCallback
import androidx.glance.appwidget.action.actionStartActivity
import androidx.glance.appwidget.cornerRadius
import androidx.glance.appwidget.lazy.LazyColumn
import androidx.glance.appwidget.lazy.items
import androidx.glance.appwidget.provideContent
import androidx.glance.background
-import androidx.glance.currentState
-import androidx.glance.appwidget.state.getAppWidgetState
import androidx.glance.state.PreferencesGlanceStateDefinition
import androidx.glance.layout.Alignment
import androidx.glance.layout.Box
@@ -47,78 +34,58 @@ import androidx.glance.layout.height
import androidx.glance.layout.padding
import androidx.glance.layout.size
import androidx.glance.layout.width
+import androidx.glance.semantics.contentDescription
+import androidx.glance.semantics.semantics
+import androidx.glance.text.TextAlign
+import androidx.glance.unit.ColorProvider
+import de.jeanlucmakiola.floret.glance.WidgetHeader
+import de.jeanlucmakiola.floret.glance.WidgetHeaderAction
import androidx.glance.text.FontWeight
import androidx.glance.text.Text
import androidx.glance.text.TextStyle
import de.jeanlucmakiola.calendula.MainActivity
import de.jeanlucmakiola.calendula.R
import de.jeanlucmakiola.calendula.data.prefs.PastEventDisplay
-import de.jeanlucmakiola.calendula.data.prefs.parsePastEventDisplay
import de.jeanlucmakiola.calendula.domain.EventInstance
import de.jeanlucmakiola.calendula.domain.hasEnded
-import de.jeanlucmakiola.calendula.ui.agenda.AgendaRange
-import de.jeanlucmakiola.calendula.ui.agenda.AgendaTimeLabel
-import de.jeanlucmakiola.calendula.ui.agenda.agendaTimeLabel
import de.jeanlucmakiola.calendula.ui.agenda.anchorTodayIfMissing
-import de.jeanlucmakiola.calendula.ui.agenda.dayCount
-import de.jeanlucmakiola.calendula.ui.agenda.parseAgendaRange
import de.jeanlucmakiola.calendula.ui.common.CalendarView
import de.jeanlucmakiola.calendula.ui.common.EventDimAlpha
-import de.jeanlucmakiola.calendula.ui.common.formatTimeOfDay
+import de.jeanlucmakiola.calendula.ui.common.formatCalendarTitle
import de.jeanlucmakiola.floret.locale.localizedDateFormatter
-import de.jeanlucmakiola.calendula.ui.common.eventAccent
import de.jeanlucmakiola.calendula.ui.common.eventFill
+import de.jeanlucmakiola.calendula.ui.common.eventInk
+import de.jeanlucmakiola.calendula.ui.common.SECONDARY_INK_ALPHA
import de.jeanlucmakiola.calendula.widget.AgendaWidgetData
-import de.jeanlucmakiola.calendula.widget.CalendulaGlanceTheme
import de.jeanlucmakiola.calendula.widget.WidgetSize
-import de.jeanlucmakiola.calendula.widget.loadAgendaWidgetData
+import de.jeanlucmakiola.calendula.widget.CalendulaWidgetRoot
+import de.jeanlucmakiola.calendula.widget.WidgetAppearance
+import de.jeanlucmakiola.calendula.widget.widgetCardColor
+import de.jeanlucmakiola.calendula.widget.widgetTimeLabel
+import de.jeanlucmakiola.calendula.widget.loadAgendaWidget
+import de.jeanlucmakiola.calendula.widget.widgetAppearance
+import de.jeanlucmakiola.floret.components.Position
+import de.jeanlucmakiola.floret.components.positionOf
+import de.jeanlucmakiola.floret.glance.WidgetGroupedRow
+import de.jeanlucmakiola.floret.glance.themedColor
+import de.jeanlucmakiola.floret.glance.wallpaperBacking
+import de.jeanlucmakiola.floret.prefs.ThemeMode
+import androidx.datastore.preferences.core.Preferences
+import androidx.glance.currentState
+import de.jeanlucmakiola.floret.glance.WidgetData
+import de.jeanlucmakiola.floret.glance.currentRefreshStamp
+import de.jeanlucmakiola.floret.glance.refreshStamp
import de.jeanlucmakiola.calendula.widget.systemZone
import de.jeanlucmakiola.calendula.widget.today
import kotlinx.datetime.DateTimeUnit
import kotlinx.datetime.LocalDate
-import kotlinx.datetime.TimeZone
import kotlinx.datetime.plus
-import kotlinx.datetime.toLocalDateTime
-import kotlin.time.Instant
+import kotlinx.datetime.toJavaDayOfWeek
import java.util.Locale
-/**
- * Per-instance Glance state key holding the agenda range (as [AgendaRange.storageValue]).
- * The range is read reactively in the composition ([currentState]) so a settings
- * change is reflected by plain recomposition — `updateAll` does NOT reliably
- * re-run the `provideGlance` preamble for a live widget session, so loading the
- * range there and slicing it would silently keep the stale value (mirrors the
- * month widget's MONTH_INDEX_KEY approach).
- */
-internal val AGENDA_RANGE_KEY = stringPreferencesKey("agenda_range")
-
-/**
- * Per-instance Glance state key holding the past-event display mode (as
- * [PastEventDisplay.name]). Read reactively in the composition for the same
- * reason as [AGENDA_RANGE_KEY] — so toggling the setting reflects on the live
- * widget without depending on the `provideGlance` preamble re-running.
- */
-internal val AGENDA_PAST_DISPLAY_KEY = stringPreferencesKey("agenda_past_display")
-
-/**
- * Per-instance Glance state key holding the "always show today" toggle (#35).
- * Read reactively in the composition for the same reason as [AGENDA_RANGE_KEY] —
- * so toggling the setting reflects on the live widget without depending on the
- * `provideGlance` preamble re-running.
- */
-internal val AGENDA_SHOW_TODAY_STATE_KEY = booleanPreferencesKey("agenda_show_today")
-
-/**
- * Per-instance Glance state key holding the chosen [WidgetSize]. Read reactively
- * in the composition for the same reason as [AGENDA_RANGE_KEY] — so changing the
- * setting reflects on the live widget without depending on the `provideGlance`
- * preamble re-running.
- */
-internal val AGENDA_SIZE_KEY = stringPreferencesKey("widget_size")
-
/**
* "Upcoming" agenda widget — a continuously scrolling list of the next ~30 days
- * of events grouped under day headers (the Google "Schedule" widget model).
+ * of events, each day one card of grouped rows with its date pill beside it.
* Reuses the app's [groupAgendaDays] grouping so it matches the in-app agenda.
*/
class AgendaWidget : GlanceAppWidget() {
@@ -131,32 +98,47 @@ class AgendaWidget : GlanceAppWidget() {
// the RemoteViews past the binder transaction limit.
override val sizeMode = SizeMode.Single
+ override val previewSizeMode = previewSizeOf(WidgetKind.AGENDA)
+
+ override suspend fun providePreview(context: Context, widgetCategory: Int) =
+ provideSamplePreview(context, WidgetKind.AGENDA)
+
override suspend fun provideGlance(context: Context, id: GlanceId) {
- val stored = getAppWidgetState(context, id)[AGENDA_RANGE_KEY]
- val data = context.loadAgendaWidgetData(stored?.let { parseAgendaRange(it, AgendaRange.Month) })
- val dark = (context.resources.configuration.uiMode and
- Configuration.UI_MODE_NIGHT_MASK) == Configuration.UI_MODE_NIGHT_YES
+ val stamp = refreshStamp(context, id)
+ val widgetData = WidgetData(context.loadAgendaWidget(id), stamp)
provideContent {
- CalendulaGlanceTheme {
- AgendaWidgetBody(data = data, dark = dark)
- }
+ // A running session never re-runs this preamble, so every redraw
+ // moves the stamp and the data reloads here instead (#420).
+ val loaded = widgetData.current(currentRefreshStamp()) { context.loadAgendaWidget(id) }
+ AgendaWidgetContent(
+ data = loaded.content,
+ appearance = currentState().widgetAppearance,
+ appTheme = loaded.appTheme,
+ )
}
}
}
/**
- * 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]).
+ * The whole agenda widget, shared with its config screen's preview (#292). The
+ * [preview] draws its rows as a plain column, which RemoteViews can show
+ * outside a widget host on every Android version.
*/
-class RefreshAgendaAction : ActionCallback {
- override suspend fun onAction(context: Context, glanceId: GlanceId, parameters: ActionParameters) {
- AgendaWidget().update(context.applicationContext, glanceId)
+@Composable
+internal fun AgendaWidgetContent(
+ data: AgendaWidgetData,
+ appearance: WidgetAppearance,
+ appTheme: ThemeMode,
+ preview: Boolean = false,
+) {
+ CalendulaWidgetRoot(appearance, appTheme) {
+ AgendaWidgetBody(data = data, preview = preview)
}
}
+/** Rows a preview draws: Glance's cap on a Column's children, still more than a placed widget shows. */
+private const val PREVIEW_ROWS = 10
+
/**
* Upper bound on rows handed to the [LazyColumn], so the serialized RemoteViews
* stays well inside the binder transaction limit regardless of range and calendar
@@ -166,57 +148,32 @@ class RefreshAgendaAction : ActionCallback {
*/
private const val MAX_AGENDA_ROWS = 100
-/** Flat row model so the [LazyColumn] can mix day headers and events. */
+/** Flat row model: one [LazyColumn] item per event or placeholder, a day's first one carrying its date pill. */
private sealed interface AgendaRow {
- data class Header(val date: LocalDate, val today: LocalDate) : AgendaRow
- data class Event(val date: LocalDate, val event: EventInstance) : AgendaRow
- /** "Nothing left today" line under an anchored, event-less today (#35). */
- data class Placeholder(val date: LocalDate) : AgendaRow
+ val date: LocalDate
+
+ data class Event(override val date: LocalDate, val event: EventInstance) : AgendaRow
+ /** "Nothing left today" line for an anchored, event-less today (#35). */
+ data class Placeholder(override val date: LocalDate) : AgendaRow
}
@Composable
-private fun AgendaWidgetBody(data: AgendaWidgetData, dark: Boolean) {
- // Read reactively from per-instance Glance state, falling back to the saved
- // pref for a freshly placed widget (#103, #51).
- // The stripe is then re-resolved against the system font scale so it tracks the
+private fun AgendaWidgetBody(data: AgendaWidgetData, preview: Boolean) {
+ // The row height is re-resolved against the system font scale so it tracks the
// sp-sized text beside it instead of drifting at large accessibility settings.
- val savedSize = (data as? AgendaWidgetData.Ready)?.savedWidgetSize ?: WidgetSize.SMALL
- val size = currentState(AGENDA_SIZE_KEY)
- ?.let { stored -> WidgetSize.entries.firstOrNull { it.name == stored } }
- ?: savedSize
+ val size = (data as? AgendaWidgetData.Ready)?.textSize ?: WidgetSize.SMALL
val fontScale = androidx.glance.LocalContext.current.resources.configuration.fontScale
val metrics = metricsFor(size).scaledForFont(fontScale)
Column(
modifier = GlanceModifier
.fillMaxSize()
- .background(GlanceTheme.colors.surface)
.padding(horizontal = 8.dp, vertical = 6.dp),
) {
- AgendaHeader(metrics)
- Spacer(GlanceModifier.height(4.dp))
+ AgendaHeader()
+ Spacer(GlanceModifier.height(6.dp))
when (data) {
AgendaWidgetData.NeedsPermission -> WidgetMessage(R.string.widget_needs_permission, metrics)
- is AgendaWidgetData.Ready -> {
- // Range read reactively from per-instance Glance state (falls back
- // to the saved pref for a freshly placed widget), then the loaded
- // window is sliced to it — pure recomposition.
- val range = parseAgendaRange(currentState(AGENDA_RANGE_KEY), data.savedRange)
- val context = androidx.glance.LocalContext.current
- var loaded by remember(data) { mutableStateOf(data) }
- // A range wider than what was loaded reloads in a live session,
- // where provideGlance doesn't run again (#363).
- LaunchedEffect(range) {
- val end = loaded.today.plus(
- range.dayCount(loaded.today, loaded.weekStart) - 1,
- DateTimeUnit.DAY,
- )
- if (end > loaded.loadedThrough) {
- (context.loadAgendaWidgetData(range) as? AgendaWidgetData.Ready)
- ?.let { loaded = it }
- }
- }
- AgendaRows(data = loaded, range = range, dark = dark, metrics = metrics)
- }
+ is AgendaWidgetData.Ready -> AgendaRows(data = data, metrics = metrics, preview = preview)
}
}
}
@@ -224,24 +181,16 @@ private fun AgendaWidgetBody(data: AgendaWidgetData, dark: Boolean) {
@Composable
private fun AgendaRows(
data: AgendaWidgetData.Ready,
- range: AgendaRange,
- dark: Boolean,
metrics: AgendaMetrics,
+ preview: Boolean,
) {
- val pastDisplay =
- parsePastEventDisplay(currentState(AGENDA_PAST_DISPLAY_KEY), data.savedPastDisplay)
- val showToday = currentState(AGENDA_SHOW_TODAY_STATE_KEY) ?: data.savedShowToday
- val rangeEnd = data.today.plus(
- range.dayCount(data.today, data.weekStart) - 1,
- DateTimeUnit.DAY,
- )
- // Slice to the chosen range, then drop finished events (and any day
- // they leave empty) when the mode is Hide — so "Upcoming" really is.
- // Finally re-anchor today (as an empty placeholder day) when the
- // "always show today" pref is on and today has no events left (#35).
+ val pastDisplay = data.pastDisplay
+ // Drop finished events (and any day they leave empty) when the mode is
+ // Hide — so "Upcoming" really is. Then re-anchor today (as an empty
+ // placeholder day) when the "always show today" pref is on and today has
+ // no events left (#35).
val visibleDays = anchorTodayIfMissing(
days = data.days
- .filter { it.date <= rangeEnd }
.let { days ->
if (pastDisplay == PastEventDisplay.HIDE) {
days.mapNotNull { day ->
@@ -253,183 +202,223 @@ private fun AgendaRows(
}
},
today = data.today,
- enabled = showToday,
+ enabled = data.showToday,
)
if (visibleDays.isEmpty()) {
WidgetMessage(R.string.agenda_empty_title, metrics)
} else {
val rows = buildList {
visibleDays.forEach { day ->
- add(AgendaRow.Header(day.date, data.today))
if (day.events.isEmpty()) {
add(AgendaRow.Placeholder(day.date))
} else {
day.events.forEach { add(AgendaRow.Event(day.date, it)) }
}
}
- }
- // Bound the payload, then drop a day header the cut left
- // stranded with nothing under it.
- .take(MAX_AGENDA_ROWS)
- .dropLastWhile { it is AgendaRow.Header }
- LazyColumn(modifier = GlanceModifier.fillMaxSize()) {
- items(rows.size) { index ->
- when (val row = rows[index]) {
- is AgendaRow.Header -> DayHeaderRow(row.date, row.today, metrics)
- is AgendaRow.Placeholder -> PlaceholderRow(row.date, metrics)
- is AgendaRow.Event -> EventRow(
- event = row.event,
- day = row.date,
- dark = dark,
- soften = data.soften,
- is24Hour = data.is24Hour,
- dimmed = pastDisplay == PastEventDisplay.DIM &&
- row.event.hasEnded(data.now),
- metrics = metrics,
- )
+ }.take(MAX_AGENDA_ROWS)
+ // Each day is one card of grouped rows (#71), with its date pill
+ // beside the first row.
+ val positions = dayPositions(rows)
+ val cardColor = widgetCardColor()
+ val item: @Composable (Int) -> Unit = { index ->
+ val row = rows[index]
+ val position = positions[index]
+ val first = position == Position.Top || position == Position.Alone
+ val dayRow: @Composable (GlanceModifier) -> Unit = { modifier ->
+ Row(modifier = modifier.fillMaxWidth()) {
+ Box(modifier = GlanceModifier.width(metrics.pillWidth)) {
+ if (first) DatePill(row.date, data.today, cardColor, metrics)
+ }
+ Spacer(GlanceModifier.width(GUTTER_GAP))
+ Box(modifier = GlanceModifier.defaultWeight()) {
+ val rowColor = when (row) {
+ is AgendaRow.Event -> row.fill(data).let { themedColor(day = it.day, night = it.night) }
+ is AgendaRow.Placeholder -> cardColor
+ }
+ WidgetGroupedRow(position = position, color = rowColor) {
+ AgendaRowContent(row, data, metrics)
+ }
+ }
}
}
+ val gap = GlanceModifier.padding(top = if (first && index > 0) metrics.dayGap else 0.dp)
+ // The pills carry no month, so a new month gets its name above its first day.
+ if (first && index > 0 && row.date.month != rows[index - 1].date.month) {
+ Column(modifier = gap.fillMaxWidth()) {
+ MonthLabel(row.date, data.today, metrics)
+ dayRow(GlanceModifier)
+ }
+ } else {
+ dayRow(gap)
+ }
+ }
+ if (preview) {
+ Column(modifier = GlanceModifier.fillMaxSize()) {
+ repeat(minOf(rows.size, PREVIEW_ROWS)) { item(it) }
+ }
+ } else {
+ LazyColumn(modifier = GlanceModifier.fillMaxSize()) {
+ items(rows.size) { item(it) }
+ }
+ }
+ }
+}
+
+/** Where each row sits among the rows of its day, for the grouped-card shapes. */
+private fun dayPositions(rows: List): List = buildList {
+ var start = 0
+ for (end in 1..rows.size) {
+ if (end == rows.size || rows[end].date != rows[start].date) {
+ for (i in start until end) add(positionOf(i - start, end - start))
+ start = end
}
}
}
@Composable
-private fun AgendaHeader(metrics: AgendaMetrics) {
- val context = androidx.glance.LocalContext.current
- Row(
- modifier = GlanceModifier.fillMaxWidth().padding(horizontal = 4.dp),
- verticalAlignment = Alignment.CenterVertically,
- ) {
- // Tap the "Upcoming" title to open the app on the user's default view
- // (issue #20) — a null target resolves to the default home in the host.
- Text(
- text = context.getString(R.string.widget_agenda_title),
- style = TextStyle(
- color = GlanceTheme.colors.primary,
- fontSize = metrics.title,
- fontWeight = FontWeight.Medium,
- ),
- modifier = GlanceModifier
- .defaultWeight()
- .clickable(
- actionStartActivity(MainActivity.openViewIntent(context, view = null)),
- ),
- )
- IconButton(
- resId = R.drawable.ic_widget_refresh,
- contentDescription = context.getString(R.string.widget_refresh),
- onClick = GlanceModifier.clickable(actionRunCallback()),
+private fun AgendaRowContent(row: AgendaRow, data: AgendaWidgetData.Ready, metrics: AgendaMetrics) {
+ when (row) {
+ is AgendaRow.Placeholder -> PlaceholderRow(row.date, metrics)
+ is AgendaRow.Event -> EventRow(
+ event = row.event,
+ day = row.date,
+ fill = row.fill(data),
+ is24Hour = data.is24Hour,
metrics = metrics,
)
- IconButton(
- resId = R.drawable.ic_widget_add,
+ }
+}
+
+@Composable
+private fun AgendaHeader() {
+ val context = androidx.glance.LocalContext.current
+ // Tap the "Upcoming" title to open the app on the user's default view
+ // (issue #20) — a null target resolves to the default home in the host.
+ WidgetHeader(
+ title = context.getString(R.string.widget_agenda_title),
+ titleAction = actionStartActivity(MainActivity.openViewIntent(context, view = null)),
+ titleInset = 4.dp,
+ ) {
+ WidgetHeaderAction(
+ icon = ImageProvider(R.drawable.ic_widget_add),
contentDescription = context.getString(R.string.widget_new_event),
- onClick = GlanceModifier.clickable(
- actionStartActivity(
- MainActivity.openCreateIntent(context, today(systemZone())),
- ),
- ),
- metrics = metrics,
+ action = actionStartActivity(MainActivity.openCreateIntent(context, today(systemZone()))),
)
}
}
+/** The month name ("November", with its year outside the current one) above a new month's first day. */
@Composable
-private fun IconButton(
- resId: Int,
- contentDescription: String,
- onClick: GlanceModifier,
- metrics: AgendaMetrics,
-) {
- Box(
- modifier = GlanceModifier.size(metrics.iconBox).then(onClick),
- contentAlignment = Alignment.Center,
- ) {
- Image(
- provider = ImageProvider(resId),
- contentDescription = contentDescription,
- colorFilter = ColorFilter.tint(GlanceTheme.colors.onSurfaceVariant),
- modifier = GlanceModifier.size(metrics.iconImage),
- )
- }
-}
-
-@Composable
-private fun DayHeaderRow(date: LocalDate, today: LocalDate, metrics: AgendaMetrics) {
- val context = androidx.glance.LocalContext.current
+private fun MonthLabel(date: LocalDate, today: LocalDate, metrics: AgendaMetrics) {
Text(
- text = agendaDayLabel(context, date, today),
+ text = monthName(date, today),
+ maxLines = 1,
style = TextStyle(
- color = if (date == today) GlanceTheme.colors.primary
- else GlanceTheme.colors.onSurfaceVariant,
- fontSize = metrics.dayHeader,
+ color = GlanceTheme.colors.onSurfaceVariant,
+ fontSize = metrics.monthLabel,
fontWeight = FontWeight.Medium,
),
modifier = GlanceModifier
- .fillMaxWidth()
- .padding(
- start = 8.dp,
- end = 8.dp,
- top = metrics.dayHeaderTopPad,
- bottom = metrics.rowVPad,
- )
- .clickable(
- actionStartActivity(
- MainActivity.openDateIntent(context, date, CalendarView.Agenda),
- ),
- ),
+ .padding(bottom = 4.dp)
+ .wallpaperBacking(radius = 8.dp)
+ .padding(horizontal = 6.dp, vertical = 2.dp),
)
}
-/**
- * "No more events today" under an anchored, event-less today (#35). Kept plain —
- * a muted line indented to the event titles — since the widget's event rows are
- * plain too (a stripe + text, not cards), so a card here would look out of place.
- */
+/** A day's weekday and number in a rounded pill, filled for today; opens that day in the app. */
+@Composable
+private fun DatePill(date: LocalDate, today: LocalDate, cardColor: ColorProvider, metrics: AgendaMetrics) {
+ val context = androidx.glance.LocalContext.current
+ val isToday = date == today
+ val container = if (isToday) GlanceTheme.colors.primary else cardColor
+ val content = if (isToday) GlanceTheme.colors.onPrimary else GlanceTheme.colors.onSurface
+ val label = agendaDayLabel(context, date, today)
+ Column(
+ modifier = GlanceModifier
+ .width(metrics.pillWidth)
+ .height(metrics.rowHeight)
+ .cornerRadius(metrics.pillWidth / 2)
+ .background(container)
+ .clickable(actionStartActivity(MainActivity.openDateIntent(context, date, CalendarView.Agenda)))
+ .semantics { contentDescription = label },
+ horizontalAlignment = Alignment.CenterHorizontally,
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ Text(
+ text = weekdayShort(date),
+ maxLines = 1,
+ style = TextStyle(color = content, fontSize = metrics.pillWeekday, textAlign = TextAlign.Center),
+ )
+ Text(
+ text = date.day.toString(),
+ maxLines = 1,
+ style = TextStyle(
+ color = content,
+ fontSize = metrics.pillDay,
+ fontWeight = FontWeight.Medium,
+ textAlign = TextAlign.Center,
+ ),
+ )
+ }
+}
+
+/** "No more events today" in an anchored, event-less today's card (#35), as tall as the pill beside it. */
@Composable
private fun PlaceholderRow(date: LocalDate, metrics: AgendaMetrics) {
val context = androidx.glance.LocalContext.current
- Text(
- text = context.getString(R.string.agenda_no_more_today),
- style = TextStyle(color = GlanceTheme.colors.onSurfaceVariant, fontSize = metrics.placeholder),
+ Box(
modifier = GlanceModifier
.fillMaxWidth()
- .padding(
- start = TEXT_INDENT,
- end = 8.dp,
- top = 2.dp,
- bottom = metrics.rowVPad + 2.dp,
- )
+ .height(metrics.rowHeight)
+ .padding(horizontal = ROW_H_PAD)
.clickable(
actionStartActivity(
MainActivity.openDateIntent(context, date, CalendarView.Agenda),
),
),
- )
+ contentAlignment = Alignment.CenterStart,
+ ) {
+ Text(
+ text = context.getString(R.string.agenda_no_more_today),
+ maxLines = 1,
+ style = TextStyle(color = GlanceTheme.colors.onSurfaceVariant, fontSize = metrics.placeholder),
+ )
+ }
+}
+
+/** An event row's light and dark fill: its calendar's container tone, faded when the row is dimmed. */
+private data class EventFill(val day: Color, val night: Color)
+
+private fun AgendaRow.Event.fill(data: AgendaWidgetData.Ready): EventFill {
+ val dimmed = data.pastDisplay == PastEventDisplay.DIM && event.hasEnded(data.now)
+ fun tone(dark: Boolean) = eventFill(event.color, dark, data.soften).let {
+ if (dimmed) it.copy(alpha = EventDimAlpha) else it
+ }
+ return EventFill(day = tone(dark = false), night = tone(dark = true))
}
@Composable
private fun EventRow(
event: EventInstance,
day: LocalDate,
- dark: Boolean,
- soften: Boolean,
+ fill: EventFill,
is24Hour: Boolean,
- dimmed: Boolean,
metrics: AgendaMetrics,
) {
val context = androidx.glance.LocalContext.current
val title = event.title.ifBlank { context.getString(R.string.event_untitled) }
- // Glance has no generic alpha modifier, so dim by fading the colour stripe and
- // dropping both text lines to the lower-emphasis on-surface-variant tone.
- val stripeColor = eventAccent(event.color, dark, soften).let {
- if (dimmed) it.copy(alpha = EventDimAlpha) else it
- }
- val titleColor = if (dimmed) GlanceTheme.colors.onSurfaceVariant else GlanceTheme.colors.onSurface
- Row(
+ // Ink is picked against the opaque fill, then carries the dim along with it.
+ fun ink(fill: Color, alpha: Float) = eventInk(fill.copy(alpha = 1f), alpha * fill.alpha)
+ val titleInk = themedColor(day = ink(fill.day, 1f), night = ink(fill.night, 1f))
+ val timeInk = themedColor(
+ day = ink(fill.day, SECONDARY_INK_ALPHA),
+ night = ink(fill.night, SECONDARY_INK_ALPHA),
+ )
+ Column(
modifier = GlanceModifier
.fillMaxWidth()
- .padding(horizontal = ROW_H_PAD, vertical = metrics.rowVPad)
+ .height(metrics.rowHeight)
+ .padding(horizontal = ROW_H_PAD)
.clickable(
actionStartActivity(
MainActivity.openEventIntent(
@@ -443,30 +432,21 @@ private fun EventRow(
),
verticalAlignment = Alignment.CenterVertically,
) {
- Box(
- modifier = GlanceModifier
- .width(STRIPE_WIDTH)
- .height(metrics.stripeH)
- .cornerRadius(3.dp)
- .background(stripeColor),
- ) {}
- Spacer(GlanceModifier.width(STRIPE_GAP))
- Column(modifier = GlanceModifier.defaultWeight()) {
- Text(
- text = title,
- maxLines = 1,
- style = TextStyle(
- color = titleColor,
- fontSize = metrics.eventTitle,
- textDecoration = glanceDeclinedDecoration(event.isDeclined),
- ),
- )
- Text(
- text = eventTimeSummary(context, event, day, is24Hour),
- maxLines = 1,
- style = TextStyle(color = GlanceTheme.colors.onSurfaceVariant, fontSize = metrics.eventTime),
- )
- }
+ Text(
+ text = title,
+ maxLines = 1,
+ style = TextStyle(
+ color = titleInk,
+ fontSize = metrics.eventTitle,
+ fontWeight = FontWeight.Medium,
+ textDecoration = glanceDeclinedDecoration(event.isDeclined),
+ ),
+ )
+ Text(
+ text = eventTimeSummary(context, event, day, is24Hour),
+ maxLines = 1,
+ style = TextStyle(color = timeInk, fontSize = metrics.eventTime),
+ )
}
}
@@ -480,12 +460,11 @@ private fun WidgetMessage(resId: Int, metrics: AgendaMetrics) {
Text(
text = context.getString(resId),
style = TextStyle(color = GlanceTheme.colors.onSurfaceVariant, fontSize = metrics.message),
+ modifier = GlanceModifier.wallpaperBacking().padding(horizontal = 12.dp, vertical = 8.dp),
)
}
}
-private fun zone(): TimeZone = systemZone()
-
/** "Today · Wed, 17 Jun" — relative word for today/tomorrow, else the date. */
private fun agendaDayLabel(context: Context, date: LocalDate, today: LocalDate): String {
val relative = when (date) {
@@ -501,26 +480,23 @@ private fun agendaDayLabel(context: Context, date: LocalDate, today: LocalDate):
return if (relative != null) "$relative · $formatted" else formatted
}
+private fun monthName(date: LocalDate, today: LocalDate): String = formatCalendarTitle(
+ date = java.time.LocalDate.of(date.year, date.month.ordinal + 1, 1),
+ locale = Locale.getDefault(),
+ currentYear = today.year,
+ skeleton = "LLLL",
+)
+
+private fun weekdayShort(date: LocalDate): String =
+ date.dayOfWeek.toJavaDayOfWeek().getDisplayName(java.time.format.TextStyle.SHORT, Locale.getDefault())
+
private fun eventTimeSummary(
context: Context,
event: EventInstance,
day: LocalDate,
is24Hour: Boolean,
): String {
- val time = when (val label = agendaTimeLabel(event, day, zone())) {
- AgendaTimeLabel.AllDay -> context.getString(R.string.event_detail_all_day)
- is AgendaTimeLabel.Starts ->
- context.getString(R.string.agenda_span_starts, formatTime(label.start, is24Hour))
- is AgendaTimeLabel.Ends ->
- context.getString(R.string.agenda_span_ends, formatTime(label.end, is24Hour))
- is AgendaTimeLabel.Range ->
- "${formatTime(label.start, is24Hour)} – ${formatTime(label.end, is24Hour)}"
- }
+ val time = widgetTimeLabel(context, event, day, is24Hour)
val location = event.location?.takeIf { it.isNotBlank() }
return if (location != null) "$time · $location" else time
}
-
-private fun formatTime(instant: Instant, is24Hour: Boolean): String {
- val t = instant.toLocalDateTime(zone()).time
- return formatTimeOfDay(t.hour, t.minute, is24Hour, Locale.getDefault())
-}
diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/widget/agenda/AgendaWidgetReceiver.kt b/app/src/main/java/de/jeanlucmakiola/calendula/widget/agenda/AgendaWidgetReceiver.kt
index 736da00..01eab36 100644
--- a/app/src/main/java/de/jeanlucmakiola/calendula/widget/agenda/AgendaWidgetReceiver.kt
+++ b/app/src/main/java/de/jeanlucmakiola/calendula/widget/agenda/AgendaWidgetReceiver.kt
@@ -1,44 +1,9 @@
package de.jeanlucmakiola.calendula.widget.agenda
-import android.appwidget.AppWidgetManager
-import android.content.Context
import androidx.glance.appwidget.GlanceAppWidget
-import androidx.glance.appwidget.GlanceAppWidgetReceiver
-import de.jeanlucmakiola.calendula.widget.WidgetRolloverScheduler
+import de.jeanlucmakiola.calendula.widget.CalendulaWidgetReceiver
-/**
- * Host-facing receiver for the agenda widget. Declared in the manifest with the
- * `appwidget_info_agenda` provider metadata; delegates all rendering to
- * [AgendaWidget].
- */
-class AgendaWidgetReceiver : GlanceAppWidgetReceiver() {
+/** Host-facing receiver for the agenda widget; rendering is [AgendaWidget]'s. */
+class AgendaWidgetReceiver : CalendulaWidgetReceiver() {
override val glanceAppWidget: GlanceAppWidget = AgendaWidget()
-
- /** First agenda widget placed — start rolling "today" over at midnight (#228). */
- override fun onEnabled(context: Context) {
- super.onEnabled(context)
- WidgetRolloverScheduler.sync(context)
- }
-
- /**
- * Last agenda widget removed. [WidgetRolloverScheduler.sync] cancels only if
- * no month widget is left either.
- */
- override fun onDisabled(context: Context) {
- super.onDisabled(context)
- WidgetRolloverScheduler.sync(context)
- }
-
- /**
- * Self-heal on the system's own `updatePeriodMillis` wake-up — see
- * [de.jeanlucmakiola.calendula.widget.month.MonthWidgetReceiver.onUpdate].
- */
- override fun onUpdate(
- context: Context,
- appWidgetManager: AppWidgetManager,
- appWidgetIds: IntArray,
- ) {
- super.onUpdate(context, appWidgetManager, appWidgetIds)
- WidgetRolloverScheduler.sync(context)
- }
}
diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/widget/config/CountdownOptionsSection.kt b/app/src/main/java/de/jeanlucmakiola/calendula/widget/config/CountdownOptionsSection.kt
new file mode 100644
index 0000000..1866d0d
--- /dev/null
+++ b/app/src/main/java/de/jeanlucmakiola/calendula/widget/config/CountdownOptionsSection.kt
@@ -0,0 +1,178 @@
+package de.jeanlucmakiola.calendula.widget.config
+
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.PaddingValues
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.foundation.lazy.itemsIndexed
+import androidx.compose.material3.CircularProgressIndicator
+import androidx.compose.material3.FilledTonalButton
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Text
+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.saveable.rememberSaveable
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.style.TextAlign
+import androidx.compose.ui.unit.dp
+import de.jeanlucmakiola.calendula.R
+import de.jeanlucmakiola.calendula.ui.common.CalendarColorChip
+import de.jeanlucmakiola.calendula.ui.common.PickerSearchField
+import de.jeanlucmakiola.calendula.widget.countdown.CountdownMode
+import de.jeanlucmakiola.calendula.widget.countdown.CountdownOptions
+import de.jeanlucmakiola.floret.components.FullScreenPicker
+import de.jeanlucmakiola.floret.components.GroupedChoiceRow
+import de.jeanlucmakiola.floret.components.GroupedHint
+import de.jeanlucmakiola.floret.components.GroupedRow
+import de.jeanlucmakiola.floret.components.GroupedSectionHeader
+import de.jeanlucmakiola.floret.components.Position
+import de.jeanlucmakiola.floret.components.positionOf
+import de.jeanlucmakiola.floret.locale.currentLocale
+import de.jeanlucmakiola.floret.locale.localizedDateFormatter
+import kotlinx.datetime.TimeZone
+import kotlinx.datetime.toJavaLocalDate
+import kotlinx.datetime.toLocalDateTime
+
+/** What the countdown widget counts to (#439): the next event, or one picked from a searchable list. */
+@Composable
+internal fun CountdownOptionsSection(
+ options: CountdownOptions,
+ candidates: CountdownCandidates,
+ onChange: (CountdownOptions) -> Unit,
+ onLoadCandidates: () -> Unit,
+) {
+ var picking by rememberSaveable { mutableStateOf(false) }
+ GroupedSectionHeader(stringResource(R.string.widget_config_countdown))
+ GroupedChoiceRow(
+ title = stringResource(R.string.widget_config_countdown),
+ options = CountdownMode.entries,
+ selected = options.mode,
+ label = {
+ stringResource(
+ when (it) {
+ CountdownMode.NEXT -> R.string.widget_config_countdown_next
+ CountdownMode.PINNED -> R.string.widget_config_countdown_pinned
+ },
+ )
+ },
+ onSelect = { mode ->
+ onChange(options.copy(mode = mode))
+ if (mode == CountdownMode.PINNED && options.eventId == null) picking = true
+ },
+ position = if (options.mode == CountdownMode.PINNED) Position.Top else Position.Alone,
+ )
+ when (options.mode) {
+ CountdownMode.NEXT -> GroupedHint(stringResource(R.string.widget_config_countdown_next_hint), verticalPadding = 8.dp)
+ CountdownMode.PINNED -> GroupedRow(
+ title = stringResource(R.string.widget_config_countdown_event),
+ summary = options.title ?: stringResource(R.string.widget_config_countdown_event_none),
+ position = Position.Bottom,
+ onClick = { picking = true },
+ )
+ }
+ if (picking) {
+ LaunchedEffect(Unit) { onLoadCandidates() }
+ CountdownEventPicker(
+ candidates = candidates,
+ selectedEventId = options.eventId,
+ onRetry = onLoadCandidates,
+ onSelect = { candidate ->
+ onChange(options.copy(mode = CountdownMode.PINNED, eventId = candidate.event.eventId, title = candidate.event.title))
+ picking = false
+ },
+ onDismiss = { picking = false },
+ )
+ }
+}
+
+@Composable
+private fun CountdownEventPicker(
+ candidates: CountdownCandidates,
+ selectedEventId: Long?,
+ onRetry: () -> Unit,
+ onSelect: (CountdownCandidate) -> Unit,
+ onDismiss: () -> Unit,
+) {
+ var query by rememberSaveable { mutableStateOf("") }
+ FullScreenPicker(
+ title = stringResource(R.string.widget_config_countdown_event),
+ onDismiss = onDismiss,
+ predictiveBack = true,
+ scrollable = false,
+ ) {
+ PickerSearchField(
+ query = query,
+ onQueryChange = { query = it },
+ placeholder = stringResource(R.string.widget_config_countdown_search),
+ modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
+ )
+ when (candidates) {
+ CountdownCandidates.Idle, CountdownCandidates.Loading -> Box(
+ modifier = Modifier.fillMaxSize(),
+ contentAlignment = Alignment.Center,
+ ) { CircularProgressIndicator() }
+ CountdownCandidates.Failed -> Column(
+ modifier = Modifier.fillMaxSize().padding(24.dp),
+ verticalArrangement = Arrangement.Center,
+ horizontalAlignment = Alignment.CenterHorizontally,
+ ) {
+ PickerMessage(stringResource(R.string.widget_config_countdown_load_failed))
+ FilledTonalButton(onClick = onRetry, modifier = Modifier.padding(top = 16.dp)) {
+ Text(stringResource(R.string.state_retry))
+ }
+ }
+ is CountdownCandidates.Ready -> {
+ val locale = currentLocale()
+ val matches = remember(candidates, query) {
+ val needle = query.trim()
+ if (needle.isEmpty()) candidates.events
+ else candidates.events.filter { it.event.title.contains(needle, ignoreCase = true) }
+ }
+ if (matches.isEmpty()) {
+ PickerMessage(
+ stringResource(R.string.widget_config_countdown_empty),
+ Modifier.fillMaxWidth().padding(24.dp),
+ )
+ } else {
+ val zone = remember { TimeZone.currentSystemDefault() }
+ val formatter = remember(locale) { localizedDateFormatter(locale, "EEEdMMMy") }
+ LazyColumn(contentPadding = PaddingValues(top = 8.dp, bottom = 24.dp)) {
+ itemsIndexed(matches, key = { _, it -> it.event.eventId }) { index, candidate ->
+ val date = formatter.format(candidate.event.start.toLocalDateTime(zone).date.toJavaLocalDate())
+ GroupedRow(
+ title = candidate.event.title.ifBlank { stringResource(R.string.event_untitled) },
+ summary = if (candidate.repeats) stringResource(R.string.widget_config_countdown_repeats, date) else date,
+ position = positionOf(index, matches.size),
+ selected = candidate.event.eventId == selectedEventId,
+ minHeight = 56.dp,
+ leading = { CalendarColorChip(candidate.event.color) },
+ onClick = { onSelect(candidate) },
+ )
+ }
+ }
+ }
+ }
+ }
+ }
+}
+
+@Composable
+private fun PickerMessage(text: String, modifier: Modifier = Modifier) {
+ Text(
+ text = text,
+ style = MaterialTheme.typography.bodyLarge,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ textAlign = TextAlign.Center,
+ modifier = modifier,
+ )
+}
diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/widget/config/PlacedWidgets.kt b/app/src/main/java/de/jeanlucmakiola/calendula/widget/config/PlacedWidgets.kt
new file mode 100644
index 0000000..458e060
--- /dev/null
+++ b/app/src/main/java/de/jeanlucmakiola/calendula/widget/config/PlacedWidgets.kt
@@ -0,0 +1,32 @@
+package de.jeanlucmakiola.calendula.widget.config
+
+import android.appwidget.AppWidgetManager
+import android.content.ComponentName
+import android.content.Context
+import androidx.glance.appwidget.GlanceAppWidgetManager
+import de.jeanlucmakiola.calendula.widget.WidgetAppearance
+import de.jeanlucmakiola.calendula.widget.WidgetKind
+import de.jeanlucmakiola.calendula.widget.widgetAppearance
+import de.jeanlucmakiola.calendula.widget.widgetState
+
+/** One of the app's widgets on the home screen, for the settings list. */
+data class PlacedWidget(val appWidgetId: Int, val kind: WidgetKind, val appearance: WidgetAppearance)
+
+/**
+ * Every placed widget, agenda ones first, each in placement order. An id the
+ * host bound but never laid out (a placement it abandoned without freeing the
+ * id) has no size yet and is left out, as is one removed while this reads.
+ */
+suspend fun Context.placedWidgets(): List {
+ val manager = AppWidgetManager.getInstance(this) ?: return emptyList()
+ val glanceManager = GlanceAppWidgetManager(this)
+ return WidgetKind.entries.flatMap { kind ->
+ manager.getAppWidgetIds(ComponentName(this, kind.receiver)).sorted().mapNotNull { id ->
+ if (manager.getAppWidgetOptions(id).getInt(AppWidgetManager.OPTION_APPWIDGET_MIN_WIDTH) <= 0) {
+ return@mapNotNull null
+ }
+ val glanceId = runCatching { glanceManager.getGlanceIdBy(id) }.getOrNull() ?: return@mapNotNull null
+ PlacedWidget(id, kind, widgetState(glanceId).widgetAppearance)
+ }
+ }
+}
diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/widget/config/WidgetCalendarPicker.kt b/app/src/main/java/de/jeanlucmakiola/calendula/widget/config/WidgetCalendarPicker.kt
new file mode 100644
index 0000000..a9122e6
--- /dev/null
+++ b/app/src/main/java/de/jeanlucmakiola/calendula/widget/config/WidgetCalendarPicker.kt
@@ -0,0 +1,60 @@
+package de.jeanlucmakiola.calendula.widget.config
+
+import androidx.compose.material3.Checkbox
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.res.pluralStringResource
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.unit.dp
+import de.jeanlucmakiola.calendula.R
+import de.jeanlucmakiola.calendula.domain.CalendarSource
+import de.jeanlucmakiola.calendula.ui.common.CalendarColorChip
+import de.jeanlucmakiola.calendula.ui.common.accountGroupTitle
+import de.jeanlucmakiola.calendula.ui.common.groupByAccount
+import de.jeanlucmakiola.calendula.ui.common.orUnnamed
+import de.jeanlucmakiola.floret.components.FullScreenPicker
+import de.jeanlucmakiola.floret.components.GroupedHint
+import de.jeanlucmakiola.floret.components.GroupedRow
+import de.jeanlucmakiola.floret.components.GroupedSectionHeader
+import de.jeanlucmakiola.floret.components.positionOf
+
+/** Which of the app's calendars one widget shows, grouped by account like the drawer's filter. */
+@Composable
+internal fun WidgetCalendarPicker(
+ calendars: List,
+ hidden: Set,
+ onSetShown: (Long, Boolean) -> Unit,
+ onDismiss: () -> Unit,
+) {
+ FullScreenPicker(
+ title = stringResource(R.string.widget_config_calendars),
+ onDismiss = onDismiss,
+ predictiveBack = true,
+ ) {
+ GroupedHint(stringResource(R.string.widget_config_calendars_hint), verticalPadding = 8.dp)
+ calendars.groupByAccount().forEach { group ->
+ GroupedSectionHeader(accountGroupTitle(group))
+ group.calendars.forEachIndexed { index, calendar ->
+ val shown = calendar.id !in hidden
+ GroupedRow(
+ title = calendar.displayName.orUnnamed(),
+ position = positionOf(index, group.calendars.size),
+ minHeight = 56.dp,
+ leading = { CalendarColorChip(calendar.color) },
+ trailing = { Checkbox(checked = shown, onCheckedChange = { onSetShown(calendar.id, it) }) },
+ onClick = { onSetShown(calendar.id, !shown) },
+ )
+ }
+ }
+ }
+}
+
+/** "All calendars", or how many of them the widget shows. */
+@Composable
+internal fun calendarsSummary(calendars: List, hidden: Set): String {
+ val shown = calendars.count { it.id !in hidden }
+ return if (shown == calendars.size) {
+ stringResource(R.string.widget_config_calendars_all)
+ } else {
+ pluralStringResource(R.plurals.widget_config_calendars_some, calendars.size, shown, calendars.size)
+ }
+}
diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/widget/config/WidgetConfigActivity.kt b/app/src/main/java/de/jeanlucmakiola/calendula/widget/config/WidgetConfigActivity.kt
new file mode 100644
index 0000000..2296bbf
--- /dev/null
+++ b/app/src/main/java/de/jeanlucmakiola/calendula/widget/config/WidgetConfigActivity.kt
@@ -0,0 +1,133 @@
+package de.jeanlucmakiola.calendula.widget.config
+
+import android.appwidget.AppWidgetManager
+import android.content.Context
+import android.content.Intent
+import android.os.Bundle
+import android.widget.Toast
+import androidx.activity.ComponentActivity
+import androidx.activity.compose.setContent
+import androidx.activity.enableEdgeToEdge
+import androidx.activity.viewModels
+import androidx.compose.foundation.isSystemInDarkTheme
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.remember
+import androidx.compose.ui.platform.LocalContext
+import androidx.lifecycle.Lifecycle
+import androidx.lifecycle.compose.collectAsStateWithLifecycle
+import androidx.lifecycle.lifecycleScope
+import androidx.lifecycle.repeatOnLifecycle
+import dagger.hilt.android.AndroidEntryPoint
+import de.jeanlucmakiola.calendula.R
+import de.jeanlucmakiola.calendula.data.prefs.SettingsKeys
+import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
+import de.jeanlucmakiola.calendula.domain.FontRole
+import de.jeanlucmakiola.calendula.ui.theme.AppFontSettings
+import de.jeanlucmakiola.calendula.ui.theme.CalendulaTheme
+import de.jeanlucmakiola.calendula.ui.theme.WindowThemeCache
+import de.jeanlucmakiola.calendula.ui.theme.calendulaTypography
+import de.jeanlucmakiola.calendula.ui.theme.resolveFontFamily
+import de.jeanlucmakiola.floret.prefs.ThemeMode
+import de.jeanlucmakiola.floret.prefs.get
+import de.jeanlucmakiola.floret.prefs.isDark
+import de.jeanlucmakiola.floret.prefs.isPureBlack
+import kotlinx.coroutines.flow.map
+import kotlinx.coroutines.launch
+import javax.inject.Inject
+
+/**
+ * A widget's config screen (#292): opened by the launcher when the widget is
+ * placed, from its "Edit widget" action on Android 12+, and from the app's
+ * widget settings. Shows over the wallpaper, so a transparent widget previews
+ * against the real home screen.
+ */
+@AndroidEntryPoint
+class WidgetConfigActivity : ComponentActivity() {
+
+ @Inject lateinit var settingsPrefs: SettingsPrefs
+
+ private val viewModel: WidgetConfigViewModel by viewModels()
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ val appWidgetId = viewModel.appWidgetId
+ // Backing out of a fresh placement leaves no unconfigured widget behind.
+ setResult(RESULT_CANCELED, result(appWidgetId))
+ if (appWidgetId == AppWidgetManager.INVALID_APPWIDGET_ID) {
+ finish()
+ return
+ }
+ enableEdgeToEdge()
+ lifecycleScope.launch {
+ repeatOnLifecycle(Lifecycle.State.STARTED) {
+ viewModel.events.collect { event ->
+ when (event) {
+ WidgetConfigEvent.SAVED -> {
+ setResult(RESULT_OK, result(appWidgetId))
+ finish()
+ }
+ WidgetConfigEvent.SAVE_FAILED ->
+ Toast.makeText(this@WidgetConfigActivity, R.string.widget_config_save_failed, Toast.LENGTH_SHORT).show()
+ }
+ }
+ }
+ }
+ val fontSettings = settingsPrefs.data.map {
+ AppFontSettings(
+ brand = it[SettingsKeys.brandFont],
+ plain = it[SettingsKeys.plainFont],
+ brandStamp = it[SettingsKeys.brandFontStamp],
+ plainStamp = it[SettingsKeys.plainFontStamp],
+ )
+ }
+ // Seeded from MainActivity's mirrored copy, so an app theme override
+ // shows from the first frame.
+ val cachedTheme = WindowThemeCache.read(this) ?: ThemeMode.SYSTEM
+ val cachedDynamicColor = WindowThemeCache.readDynamicColor(this) ?: true
+ setContent {
+ val themeMode by settingsPrefs.themeMode.collectAsStateWithLifecycle(cachedTheme)
+ val dynamicColor by settingsPrefs.dynamicColor.collectAsStateWithLifecycle(cachedDynamicColor)
+ val fonts by fontSettings.collectAsStateWithLifecycle(initialValue = null)
+ val context = LocalContext.current
+ // Nothing until the font choice is known, rather than a frame in the default one.
+ val typography = remember(fonts, context) {
+ fonts?.let {
+ calendulaTypography(
+ brand = resolveFontFamily(it.brand, FontRole.BRAND, context),
+ plain = resolveFontFamily(it.plain, FontRole.PLAIN, context),
+ )
+ }
+ } ?: return@setContent
+ CalendulaTheme(
+ darkTheme = themeMode.isDark(isSystemInDarkTheme()),
+ dynamicColor = dynamicColor,
+ pureBlack = themeMode.isPureBlack,
+ typography = typography,
+ ) {
+ val state by viewModel.state.collectAsStateWithLifecycle()
+ if (state is WidgetConfigUiState.Missing) LaunchedEffect(Unit) { finish() }
+ WidgetConfigScreen(
+ state = state,
+ onClose = ::finish,
+ onSave = viewModel::save,
+ onRetry = viewModel::retry,
+ onAppearance = viewModel::setAppearance,
+ onContent = viewModel::setContent,
+ onCalendarShown = viewModel::setCalendarShown,
+ onCountdown = viewModel::setCountdown,
+ onLoadCountdownCandidates = viewModel::loadCountdownCandidates,
+ )
+ }
+ }
+ }
+
+ private fun result(appWidgetId: Int) = Intent().putExtra(AppWidgetManager.EXTRA_APPWIDGET_ID, appWidgetId)
+
+ companion object {
+ /** Opens placed widget [appWidgetId]'s config screen from inside the app. */
+ fun intent(context: Context, appWidgetId: Int): Intent =
+ Intent(context, WidgetConfigActivity::class.java)
+ .putExtra(AppWidgetManager.EXTRA_APPWIDGET_ID, appWidgetId)
+ }
+}
diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/widget/config/WidgetConfigScreen.kt b/app/src/main/java/de/jeanlucmakiola/calendula/widget/config/WidgetConfigScreen.kt
new file mode 100644
index 0000000..f410e6e
--- /dev/null
+++ b/app/src/main/java/de/jeanlucmakiola/calendula/widget/config/WidgetConfigScreen.kt
@@ -0,0 +1,396 @@
+package de.jeanlucmakiola.calendula.widget.config
+
+import androidx.compose.foundation.background
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.navigationBarsPadding
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.rememberScrollState
+import androidx.compose.foundation.verticalScroll
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.Close
+import androidx.compose.material3.Button
+import androidx.compose.material3.CircularProgressIndicator
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.FilledTonalButton
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Text
+import androidx.compose.material3.TopAppBar
+import androidx.compose.material3.TopAppBarDefaults
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.saveable.rememberSaveable
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.style.TextAlign
+import androidx.compose.ui.text.style.TextOverflow
+import androidx.compose.ui.unit.DpSize
+import androidx.compose.ui.unit.dp
+import de.jeanlucmakiola.calendula.R
+import de.jeanlucmakiola.calendula.data.prefs.PastEventDisplay
+import de.jeanlucmakiola.calendula.ui.common.AgendaRangePicker
+import de.jeanlucmakiola.calendula.ui.common.agendaRangeLabel
+import de.jeanlucmakiola.calendula.ui.settings.pastEventDisplayLabelRes
+import de.jeanlucmakiola.calendula.widget.AgendaWidgetOptions
+import de.jeanlucmakiola.calendula.widget.WidgetAppearance
+import de.jeanlucmakiola.calendula.widget.WidgetOption
+import de.jeanlucmakiola.calendula.widget.WidgetSize
+import de.jeanlucmakiola.calendula.widget.WidgetTheme
+import de.jeanlucmakiola.calendula.widget.agenda.AgendaWidgetContent
+import de.jeanlucmakiola.calendula.widget.agenda.metricsFor
+import de.jeanlucmakiola.calendula.widget.month.MonthWidgetContent
+import de.jeanlucmakiola.calendula.widget.quickadd.QuickAddWidgetContent
+import de.jeanlucmakiola.calendula.widget.date.DateWidgetContent
+import de.jeanlucmakiola.calendula.widget.week.WeekStripWidgetContent
+import de.jeanlucmakiola.calendula.widget.upcoming.UpcomingWidgetContent
+import de.jeanlucmakiola.calendula.widget.today.TodayWidgetContent
+import de.jeanlucmakiola.calendula.widget.countdown.CountdownOptions
+import de.jeanlucmakiola.calendula.widget.countdown.CountdownWidgetContent
+import de.jeanlucmakiola.calendula.widget.split.MonthDayWidgetContent
+import de.jeanlucmakiola.calendula.widget.WidgetKind
+import de.jeanlucmakiola.floret.components.GroupedChoiceRow
+import de.jeanlucmakiola.floret.components.GroupedHint
+import de.jeanlucmakiola.floret.components.GroupedRow
+import de.jeanlucmakiola.floret.components.GroupedSectionHeader
+import de.jeanlucmakiola.floret.components.GroupedSwitchRow
+import de.jeanlucmakiola.floret.components.OptionPicker
+import de.jeanlucmakiola.floret.components.Position
+import de.jeanlucmakiola.floret.components.positionOf
+import de.jeanlucmakiola.floret.glance.GlanceWidgetPreview
+import de.jeanlucmakiola.floret.glance.WallpaperWindow
+import de.jeanlucmakiola.floret.glance.WidgetBackground
+import kotlin.math.roundToInt
+
+private val PreviewMaxHeight = 280.dp
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+internal fun WidgetConfigScreen(
+ state: WidgetConfigUiState,
+ onClose: () -> Unit,
+ onSave: () -> Unit,
+ onRetry: () -> Unit,
+ onAppearance: (WidgetAppearance) -> Unit,
+ onContent: (AgendaWidgetOptions) -> Unit,
+ onCalendarShown: (Long, Boolean) -> Unit,
+ onCountdown: (CountdownOptions) -> Unit,
+ onLoadCountdownCandidates: () -> Unit,
+) {
+ val ready = state as? WidgetConfigUiState.Ready
+ val surface = MaterialTheme.colorScheme.surface
+ Column(modifier = Modifier.fillMaxSize()) {
+ TopAppBar(
+ title = {
+ Text(
+ text = ready?.let { stringResource(it.kind.label) }.orEmpty(),
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis,
+ )
+ },
+ navigationIcon = {
+ IconButton(onClick = onClose) {
+ Icon(Icons.Default.Close, contentDescription = stringResource(R.string.event_edit_close))
+ }
+ },
+ actions = {
+ Button(
+ onClick = onSave,
+ enabled = ready != null && !ready.saving,
+ modifier = Modifier.padding(end = 8.dp),
+ ) {
+ Text(stringResource(R.string.dialog_save))
+ }
+ },
+ colors = TopAppBarDefaults.topAppBarColors(containerColor = surface),
+ )
+ when (state) {
+ WidgetConfigUiState.Loading, WidgetConfigUiState.Missing -> Box(
+ modifier = Modifier.fillMaxSize().background(surface),
+ contentAlignment = Alignment.Center,
+ ) {
+ CircularProgressIndicator()
+ }
+ WidgetConfigUiState.Failed -> Column(
+ modifier = Modifier.fillMaxSize().background(surface).padding(24.dp),
+ verticalArrangement = Arrangement.Center,
+ horizontalAlignment = Alignment.CenterHorizontally,
+ ) {
+ Text(
+ text = stringResource(R.string.widget_config_load_failed),
+ style = MaterialTheme.typography.bodyLarge,
+ textAlign = TextAlign.Center,
+ )
+ Spacer(Modifier.height(16.dp))
+ FilledTonalButton(onClick = onRetry) { Text(stringResource(R.string.state_retry)) }
+ }
+ is WidgetConfigUiState.Ready -> {
+ WallpaperWindow(color = surface) {
+ val maxSize = DpSize(maxWidth, PreviewMaxHeight)
+ when (val preview = state.preview) {
+ null -> Box(
+ modifier = Modifier.padding(vertical = 16.dp).height(maxSize.height / 2),
+ contentAlignment = Alignment.Center,
+ ) {
+ if (state.previewFailed) PreviewFailed() else CircularProgressIndicator()
+ }
+ else -> GlanceWidgetPreview(
+ size = state.placedSize,
+ maxSize = maxSize,
+ preview,
+ state.appearance,
+ state.appTheme,
+ modifier = Modifier.padding(vertical = 16.dp),
+ placeholder = { CircularProgressIndicator() },
+ failure = { PreviewFailed() },
+ ) {
+ when (preview) {
+ is WidgetPreviewData.Agenda ->
+ AgendaWidgetContent(preview.data, state.appearance, state.appTheme, preview = true)
+ is WidgetPreviewData.Month ->
+ MonthWidgetContent(preview.source, preview.index, state.appearance, state.appTheme)
+ is WidgetPreviewData.MonthDay -> MonthDayWidgetContent(
+ preview.data,
+ preview.index,
+ selected = null,
+ appearance = state.appearance,
+ appTheme = state.appTheme,
+ preview = true,
+ )
+ is WidgetPreviewData.Countdown ->
+ CountdownWidgetContent(preview.data, state.appearance, state.appTheme)
+ is WidgetPreviewData.Days -> when (state.kind) {
+ WidgetKind.TODAY ->
+ TodayWidgetContent(preview.data, state.appearance, state.appTheme, preview = true)
+ WidgetKind.UPCOMING -> UpcomingWidgetContent(preview.data, state.appearance, state.appTheme)
+ else -> WeekStripWidgetContent(preview.data, state.appearance, state.appTheme)
+ }
+ is WidgetPreviewData.Today -> if (state.kind == WidgetKind.DATE) {
+ DateWidgetContent(preview.today, state.appearance, state.appTheme)
+ } else {
+ QuickAddWidgetContent(preview.today, state.appearance, state.appTheme)
+ }
+ }
+ }
+ }
+ }
+ ConfigOptions(
+ state = state,
+ onAppearance = onAppearance,
+ onContent = onContent,
+ onCalendarShown = onCalendarShown,
+ onCountdown = onCountdown,
+ onLoadCountdownCandidates = onLoadCountdownCandidates,
+ modifier = Modifier
+ .weight(1f)
+ .background(surface),
+ )
+ }
+ }
+ }
+}
+
+@Composable
+private fun PreviewFailed() {
+ Text(
+ text = stringResource(R.string.widget_config_preview_failed),
+ style = MaterialTheme.typography.bodyMedium,
+ color = MaterialTheme.colorScheme.onSurface,
+ textAlign = TextAlign.Center,
+ modifier = Modifier
+ .background(MaterialTheme.colorScheme.surface, MaterialTheme.shapes.large)
+ .padding(horizontal = 16.dp, vertical = 8.dp),
+ )
+}
+
+@Composable
+private fun ConfigOptions(
+ state: WidgetConfigUiState.Ready,
+ onAppearance: (WidgetAppearance) -> Unit,
+ onContent: (AgendaWidgetOptions) -> Unit,
+ onCalendarShown: (Long, Boolean) -> Unit,
+ onCountdown: (CountdownOptions) -> Unit,
+ onLoadCountdownCandidates: () -> Unit,
+ modifier: Modifier = Modifier,
+) {
+ Column(
+ modifier = modifier
+ .verticalScroll(rememberScrollState())
+ .navigationBarsPadding()
+ .padding(bottom = 16.dp),
+ ) {
+ GroupedSectionHeader(stringResource(R.string.settings_section_appearance))
+ GroupedChoiceRow(
+ title = stringResource(R.string.widget_config_background),
+ options = WidgetBackground.entries,
+ selected = state.appearance.background,
+ label = { backgroundLabel(it) },
+ onSelect = { onAppearance(state.appearance.copy(background = it)) },
+ position = Position.Top,
+ )
+ GroupedChoiceRow(
+ title = stringResource(R.string.settings_theme),
+ options = WidgetTheme.entries,
+ selected = state.appearance.theme,
+ label = { themeLabel(it) },
+ onSelect = { onAppearance(state.appearance.copy(theme = it)) },
+ position = Position.Bottom,
+ )
+ if (state.kind == WidgetKind.COUNTDOWN) {
+ CountdownOptionsSection(
+ options = state.draft.countdown,
+ candidates = state.candidates,
+ onChange = onCountdown,
+ onLoadCandidates = onLoadCountdownCandidates,
+ )
+ }
+ if (state.kind.options.isNotEmpty()) {
+ ContentOptions(state = state, onChange = onContent, onCalendarShown = onCalendarShown)
+ }
+ }
+}
+
+@Composable
+private fun ContentOptions(
+ state: WidgetConfigUiState.Ready,
+ onChange: (AgendaWidgetOptions) -> Unit,
+ onCalendarShown: (Long, Boolean) -> Unit,
+) {
+ val options = state.draft.content
+ var showRange by rememberSaveable { mutableStateOf(false) }
+ var showSize by rememberSaveable { mutableStateOf(false) }
+ var showPast by rememberSaveable { mutableStateOf(false) }
+ var showCalendars by rememberSaveable { mutableStateOf(false) }
+
+ // Rows in WidgetOption order, each shaped by where it lands among the kind's own.
+ val rows = WidgetOption.entries.filter { it in state.kind.options }
+ fun position(option: WidgetOption) = positionOf(rows.indexOf(option), rows.size)
+
+ GroupedSectionHeader(stringResource(R.string.widget_config_content))
+ rows.forEach { option ->
+ when (option) {
+ WidgetOption.RANGE -> GroupedRow(
+ title = stringResource(R.string.widget_config_range),
+ summary = agendaRangeLabel(options.range),
+ position = position(option),
+ onClick = { showRange = true },
+ )
+ WidgetOption.TEXT_SIZE -> GroupedRow(
+ title = stringResource(R.string.widget_config_text_size),
+ summary = widgetSizeLabel(options.textSize),
+ position = position(option),
+ onClick = { showSize = true },
+ )
+ WidgetOption.PAST_DISPLAY -> GroupedRow(
+ title = stringResource(R.string.settings_past_events),
+ summary = stringResource(pastEventDisplayLabelRes(options.pastDisplay)),
+ position = position(option),
+ onClick = { showPast = true },
+ )
+ WidgetOption.SHOW_TODAY -> GroupedSwitchRow(
+ title = stringResource(R.string.settings_agenda_show_today),
+ checked = options.showToday,
+ onCheckedChange = { onChange(options.copy(showToday = it)) },
+ position = position(option),
+ )
+ WidgetOption.CALENDARS -> GroupedRow(
+ title = stringResource(R.string.widget_config_calendars),
+ summary = calendarsSummary(state.calendars, state.draft.hiddenCalendars),
+ position = position(option),
+ onClick = { showCalendars = true },
+ )
+ }
+ }
+
+ if (showCalendars) {
+ WidgetCalendarPicker(
+ calendars = state.calendars,
+ hidden = state.draft.hiddenCalendars,
+ onSetShown = onCalendarShown,
+ onDismiss = { showCalendars = false },
+ )
+ }
+
+ if (showRange) {
+ AgendaRangePicker(
+ title = stringResource(R.string.widget_config_range),
+ description = stringResource(R.string.settings_agenda_widget_range_hint),
+ selected = options.range,
+ weekStart = state.weekStart,
+ onSelect = { onChange(options.copy(range = it)) },
+ onDismiss = { showRange = false },
+ )
+ }
+ if (showSize) {
+ OptionPicker(
+ title = stringResource(R.string.widget_config_text_size),
+ header = { GroupedHint(stringResource(R.string.settings_widget_size_hint), verticalPadding = 8.dp) },
+ predictiveBack = true,
+ options = WidgetSize.entries,
+ selected = options.textSize,
+ label = { widgetSizeLabel(it) },
+ summary = { widgetSizeSummary(it) },
+ onSelect = { onChange(options.copy(textSize = it)) },
+ onDismiss = { showSize = false },
+ )
+ }
+ if (showPast) {
+ OptionPicker(
+ title = stringResource(R.string.settings_past_events),
+ header = { GroupedHint(stringResource(R.string.settings_past_events_hint), verticalPadding = 8.dp) },
+ predictiveBack = true,
+ options = PastEventDisplay.entries,
+ selected = options.pastDisplay,
+ label = { stringResource(pastEventDisplayLabelRes(it)) },
+ onSelect = { onChange(options.copy(pastDisplay = it)) },
+ onDismiss = { showPast = false },
+ )
+ }
+}
+
+@Composable
+internal fun backgroundLabel(background: WidgetBackground): String = stringResource(
+ when (background) {
+ WidgetBackground.SOLID -> R.string.widget_config_background_solid
+ WidgetBackground.TINTED -> R.string.widget_config_background_tinted
+ WidgetBackground.TRANSPARENT -> R.string.widget_config_background_transparent
+ },
+)
+
+@Composable
+internal fun themeLabel(theme: WidgetTheme): String = stringResource(
+ when (theme) {
+ WidgetTheme.FOLLOW_APP -> R.string.widget_config_theme_follow_app
+ WidgetTheme.LIGHT -> R.string.settings_theme_light
+ WidgetTheme.DARK -> R.string.settings_theme_dark
+ },
+)
+
+/**
+ * What a size step does, as a percentage of the smallest one's event text.
+ * Derived from the widget's own [metricsFor] table rather than hardcoded.
+ */
+@Composable
+private fun widgetSizeSummary(size: WidgetSize): String {
+ val base = metricsFor(WidgetSize.SMALL).eventTitle.value
+ val percent = (metricsFor(size).eventTitle.value / base * 100f).roundToInt()
+ return stringResource(R.string.settings_widget_size_summary, percent)
+}
+
+@Composable
+private fun widgetSizeLabel(size: WidgetSize): String = stringResource(
+ when (size) {
+ WidgetSize.SMALL -> R.string.settings_widget_size_small
+ WidgetSize.MEDIUM -> R.string.settings_widget_size_medium
+ WidgetSize.LARGE -> R.string.settings_widget_size_large
+ WidgetSize.EXTRA_LARGE -> R.string.settings_widget_size_extra_large
+ },
+)
diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/widget/config/WidgetConfigViewModel.kt b/app/src/main/java/de/jeanlucmakiola/calendula/widget/config/WidgetConfigViewModel.kt
new file mode 100644
index 0000000..5d44ea6
--- /dev/null
+++ b/app/src/main/java/de/jeanlucmakiola/calendula/widget/config/WidgetConfigViewModel.kt
@@ -0,0 +1,335 @@
+package de.jeanlucmakiola.calendula.widget.config
+
+import android.appwidget.AppWidgetManager
+import android.content.Context
+import androidx.compose.ui.unit.DpSize
+import androidx.compose.ui.unit.dp
+import androidx.glance.appwidget.GlanceAppWidgetManager
+import androidx.lifecycle.SavedStateHandle
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.viewModelScope
+import dagger.hilt.android.lifecycle.HiltViewModel
+import dagger.hilt.android.qualifiers.ApplicationContext
+import de.jeanlucmakiola.calendula.data.calendar.CalendarRepository
+import de.jeanlucmakiola.calendula.data.prefs.CalendarPrefs
+import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
+import de.jeanlucmakiola.calendula.domain.CalendarSource
+import de.jeanlucmakiola.calendula.widget.WidgetKind
+import de.jeanlucmakiola.calendula.widget.DaysWidgetData
+import de.jeanlucmakiola.calendula.widget.WidgetLoadContext
+import de.jeanlucmakiola.calendula.widget.WidgetSettings
+import de.jeanlucmakiola.calendula.widget.loadDays
+import de.jeanlucmakiola.calendula.widget.weekOf
+import de.jeanlucmakiola.calendula.widget.upcoming.loadUpcoming
+import de.jeanlucmakiola.calendula.widget.today.loadTodayList
+import de.jeanlucmakiola.calendula.domain.EventInstance
+import de.jeanlucmakiola.calendula.widget.countdown.CountdownData
+import de.jeanlucmakiola.calendula.widget.countdown.CountdownOptions
+import de.jeanlucmakiola.calendula.widget.countdown.countdownOptions
+import de.jeanlucmakiola.calendula.widget.countdown.loadCountdown
+import de.jeanlucmakiola.calendula.widget.countdown.putCountdownOptions
+import de.jeanlucmakiola.calendula.widget.split.MonthDayData
+import de.jeanlucmakiola.calendula.widget.split.loadMonthDay
+import kotlin.time.Clock
+import kotlin.time.Duration.Companion.days
+import de.jeanlucmakiola.calendula.widget.WidgetOption
+import de.jeanlucmakiola.calendula.widget.hiddenCalendars
+import de.jeanlucmakiola.calendula.widget.putHiddenCalendars
+import de.jeanlucmakiola.calendula.data.prefs.resolveFirstDay
+import de.jeanlucmakiola.calendula.widget.AgendaWidgetData
+import de.jeanlucmakiola.calendula.widget.AgendaWidgetOptions
+import de.jeanlucmakiola.calendula.widget.MonthWidgetSource
+import de.jeanlucmakiola.calendula.widget.WidgetAppearance
+import de.jeanlucmakiola.calendula.widget.loadAgendaWidgetData
+import de.jeanlucmakiola.calendula.widget.loadThemedMonthSource
+import de.jeanlucmakiola.calendula.widget.monthIndexOf
+import de.jeanlucmakiola.calendula.widget.monthWindowCenter
+import de.jeanlucmakiola.calendula.widget.agendaWidgetOptions
+import de.jeanlucmakiola.calendula.widget.putAgendaOptions
+import de.jeanlucmakiola.calendula.widget.putAppearance
+import de.jeanlucmakiola.calendula.widget.systemZone
+import de.jeanlucmakiola.calendula.widget.today
+import de.jeanlucmakiola.calendula.widget.widgetAppearance
+import de.jeanlucmakiola.calendula.widget.widgetSettings
+import de.jeanlucmakiola.calendula.widget.widgetState
+import de.jeanlucmakiola.floret.glance.updateWithRefresh
+import de.jeanlucmakiola.floret.prefs.ThemeMode
+import javax.inject.Inject
+import kotlinx.coroutines.CancellationException
+import kotlinx.coroutines.Job
+import kotlinx.coroutines.channels.Channel
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.receiveAsFlow
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.asStateFlow
+import kotlinx.coroutines.flow.first
+import kotlinx.coroutines.flow.update
+import kotlinx.coroutines.launch
+import kotlinx.datetime.DayOfWeek
+import kotlinx.datetime.LocalDate
+import java.util.Locale
+
+/** What the preview draws, loaded with the draft's options. */
+sealed interface WidgetPreviewData {
+ data class Agenda(val data: AgendaWidgetData) : WidgetPreviewData
+ data class Month(val source: MonthWidgetSource, val index: Int) : WidgetPreviewData
+ data class Today(val today: LocalDate) : WidgetPreviewData
+ data class Days(val data: DaysWidgetData) : WidgetPreviewData
+ data class Countdown(val data: CountdownData) : WidgetPreviewData
+ data class MonthDay(val data: MonthDayData, val index: Int) : WidgetPreviewData
+}
+
+/** The options being edited, applied to the preview as they change and written on save. */
+data class WidgetDraft(
+ val appearance: WidgetAppearance,
+ /** The content options; stored only for a kind that [WidgetKind.hasContentOptions]. */
+ val content: AgendaWidgetOptions,
+ val hiddenCalendars: Set,
+ val countdown: CountdownOptions = CountdownOptions(),
+)
+
+/** One event or series the countdown can be pinned to, at its next occurrence. */
+data class CountdownCandidate(val event: EventInstance, val repeats: Boolean)
+
+/** The countdown's event list, loaded when its picker first opens. */
+sealed interface CountdownCandidates {
+ data object Idle : CountdownCandidates
+ data object Loading : CountdownCandidates
+ data object Failed : CountdownCandidates
+ data class Ready(val events: List) : CountdownCandidates
+}
+
+private fun WidgetDraft.loadContext(settings: WidgetSettings) = WidgetLoadContext(settings, content, hiddenCalendars)
+
+sealed interface WidgetConfigUiState {
+ data object Loading : WidgetConfigUiState
+
+ /** The widget is gone, or isn't one of ours; the screen closes. */
+ data object Missing : WidgetConfigUiState
+
+ /** Its stored options couldn't be read. */
+ data object Failed : WidgetConfigUiState
+
+ data class Ready(
+ val kind: WidgetKind,
+ val draft: WidgetDraft,
+ /** The calendars the app shows, for the widget's own calendar choice. */
+ val calendars: List,
+ val appTheme: ThemeMode,
+ val weekStart: DayOfWeek,
+ /** The widget's size on the home screen, which the preview is laid out at. */
+ val placedSize: DpSize,
+ /** Null until the first preview load lands. */
+ val preview: WidgetPreviewData?,
+ val candidates: CountdownCandidates = CountdownCandidates.Idle,
+ /** The first preview load failed, so there's nothing to show. */
+ val previewFailed: Boolean = false,
+ val saving: Boolean = false,
+ ) : WidgetConfigUiState {
+ val appearance: WidgetAppearance get() = draft.appearance
+ }
+}
+
+/** One-time outcomes of a save, for the screen that's showing when they land. */
+enum class WidgetConfigEvent { SAVED, SAVE_FAILED }
+
+/**
+ * Backs [WidgetConfigActivity] (#292): the placed widget's stored options as an
+ * editable draft, a preview loaded with that draft, and the save that writes it
+ * into the widget's Glance state and redraws it.
+ */
+@HiltViewModel
+class WidgetConfigViewModel @Inject constructor(
+ @ApplicationContext private val context: Context,
+ private val prefs: SettingsPrefs,
+ private val calendarPrefs: CalendarPrefs,
+ private val repository: CalendarRepository,
+ savedState: SavedStateHandle,
+) : ViewModel() {
+
+ val appWidgetId: Int = savedState[AppWidgetManager.EXTRA_APPWIDGET_ID] ?: AppWidgetManager.INVALID_APPWIDGET_ID
+
+ private val _state = MutableStateFlow(WidgetConfigUiState.Loading)
+ val state: StateFlow = _state.asStateFlow()
+
+ private val _events = Channel(Channel.BUFFERED)
+ val events: Flow = _events.receiveAsFlow()
+
+ private var previewJob: Job? = null
+
+ init {
+ viewModelScope.launch { load() }
+ }
+
+ fun retry() {
+ if (_state.value != WidgetConfigUiState.Failed) return
+ _state.value = WidgetConfigUiState.Loading
+ viewModelScope.launch { load() }
+ }
+
+ private suspend fun load() {
+ val kind = WidgetKind.of(context, appWidgetId)
+ if (kind == null) {
+ _state.value = WidgetConfigUiState.Missing
+ return
+ }
+ _state.value = try {
+ val stored = context.widgetState(GlanceAppWidgetManager(context).getGlanceIdBy(appWidgetId))
+ val settings = prefs.widgetSettings.first()
+ WidgetConfigUiState.Ready(
+ kind = kind,
+ draft = WidgetDraft(
+ appearance = stored.widgetAppearance,
+ content = stored.agendaWidgetOptions(settings.agendaDefaults),
+ hiddenCalendars = stored.hiddenCalendars,
+ countdown = stored.countdownOptions,
+ ),
+ calendars = if (WidgetOption.CALENDARS in kind.options) shownCalendars() else emptyList(),
+ appTheme = settings.themeMode,
+ weekStart = settings.weekStart.resolveFirstDay(Locale.getDefault()),
+ placedSize = placedSize(kind),
+ preview = null,
+ )
+ } catch (e: CancellationException) {
+ throw e
+ } catch (_: IllegalArgumentException) {
+ // getGlanceIdBy: the widget was removed meanwhile.
+ WidgetConfigUiState.Missing
+ } catch (_: Exception) {
+ WidgetConfigUiState.Failed
+ }
+ reloadPreview()
+ }
+
+ /** The size the launcher placed the widget at (portrait), or a default before it has reported one. */
+ private fun placedSize(kind: WidgetKind): DpSize {
+ val options = AppWidgetManager.getInstance(context)?.getAppWidgetOptions(appWidgetId)
+ val width = options?.getInt(AppWidgetManager.OPTION_APPWIDGET_MIN_WIDTH) ?: 0
+ val height = options?.getInt(AppWidgetManager.OPTION_APPWIDGET_MAX_HEIGHT) ?: 0
+ if (width > 0 && height > 0) return DpSize(width.dp, height.dp)
+ return kind.defaultSize
+ }
+
+ /** The calendars the app itself shows; the ones hidden there never reach a widget anyway. */
+ private suspend fun shownCalendars(): List {
+ val hidden = calendarPrefs.hiddenCalendarIds.first()
+ return repository.calendars().first().filter { it.isVisibleInSystem && it.id !in hidden }
+ }
+
+ fun setAppearance(appearance: WidgetAppearance) = edit { it.copy(draft = it.draft.copy(appearance = appearance)) }
+
+ fun setContent(options: AgendaWidgetOptions) {
+ edit { it.copy(draft = it.draft.copy(content = options)) }
+ reloadPreview()
+ }
+
+ fun setCountdown(options: CountdownOptions) {
+ edit { it.copy(draft = it.draft.copy(countdown = options)) }
+ reloadPreview()
+ }
+
+ /** Loads the events the countdown can be pinned to, unless they're loaded or loading. */
+ fun loadCountdownCandidates() {
+ val ready = _state.value as? WidgetConfigUiState.Ready ?: return
+ if (ready.candidates is CountdownCandidates.Loading || ready.candidates is CountdownCandidates.Ready) return
+ edit { it.copy(candidates = CountdownCandidates.Loading) }
+ viewModelScope.launch {
+ val candidates = try {
+ val now = Clock.System.now()
+ val occurrences = repository.instances(now..now + 365.days).first().filter { it.end > now }
+ val perEvent = occurrences.groupBy { it.eventId }
+ CountdownCandidates.Ready(
+ perEvent.values
+ .map { CountdownCandidate(it.minBy { o -> o.start }, repeats = it.size > 1) }
+ .sortedBy { it.event.start },
+ )
+ } catch (e: CancellationException) {
+ throw e
+ } catch (_: Exception) {
+ CountdownCandidates.Failed
+ }
+ edit { it.copy(candidates = candidates) }
+ }
+ }
+
+ fun setCalendarShown(id: Long, shown: Boolean) {
+ edit {
+ val hidden = it.draft.hiddenCalendars
+ it.copy(draft = it.draft.copy(hiddenCalendars = if (shown) hidden - id else hidden + id))
+ }
+ reloadPreview()
+ }
+
+ private fun edit(change: (WidgetConfigUiState.Ready) -> WidgetConfigUiState.Ready) {
+ _state.update { if (it is WidgetConfigUiState.Ready) change(it) else it }
+ }
+
+ private fun reloadPreview() {
+ val ready = _state.value as? WidgetConfigUiState.Ready ?: return
+ previewJob?.cancel()
+ previewJob = viewModelScope.launch {
+ val preview = try {
+ loadPreview(ready)
+ } catch (e: CancellationException) {
+ throw e
+ } catch (_: Exception) {
+ // The options stay editable; the preview keeps what it had.
+ edit { it.copy(previewFailed = it.preview == null) }
+ return@launch
+ }
+ edit { it.copy(preview = preview, previewFailed = false) }
+ }
+ }
+
+ private suspend fun loadPreview(ready: WidgetConfigUiState.Ready): WidgetPreviewData {
+ val draft = ready.draft
+ val settings = prefs.widgetSettings.first()
+ return when (ready.kind) {
+ WidgetKind.AGENDA ->
+ WidgetPreviewData.Agenda(context.loadAgendaWidgetData(settings, draft.content, draft.hiddenCalendars))
+ WidgetKind.MONTH -> {
+ val index = monthIndexOf(today(systemZone()))
+ val source = context.loadThemedMonthSource(monthWindowCenter(index), draft.hiddenCalendars)
+ WidgetPreviewData.Month(source.content, index)
+ }
+ WidgetKind.QUICK_ADD, WidgetKind.DATE -> WidgetPreviewData.Today(today(systemZone()))
+ WidgetKind.UPCOMING -> WidgetPreviewData.Days(context.loadUpcoming(draft.loadContext(settings)).content)
+ WidgetKind.TODAY -> WidgetPreviewData.Days(context.loadTodayList(draft.loadContext(settings)).content)
+ WidgetKind.MONTH_DAY -> {
+ val index = monthIndexOf(today(systemZone()))
+ val source = context.loadThemedMonthSource(monthWindowCenter(index), draft.hiddenCalendars)
+ WidgetPreviewData.MonthDay(context.loadMonthDay(source, draft.content).content, index)
+ }
+ WidgetKind.COUNTDOWN ->
+ WidgetPreviewData.Countdown(context.loadCountdown(draft.loadContext(settings), draft.countdown, null).content)
+ WidgetKind.WEEK_STRIP -> WidgetPreviewData.Days(context.loadDays(draft.loadContext(settings), ::weekOf).content)
+ }
+ }
+
+ /** Writes the draft into the widget and redraws it, then reports how that went on [events]. */
+ fun save() {
+ val ready = _state.value as? WidgetConfigUiState.Ready ?: return
+ if (ready.saving) return
+ edit { it.copy(saving = true) }
+ viewModelScope.launch {
+ try {
+ val glanceId = GlanceAppWidgetManager(context).getGlanceIdBy(appWidgetId)
+ ready.kind.widget().updateWithRefresh(context, glanceId) { state ->
+ state.putAppearance(ready.draft.appearance)
+ if (ready.kind.hasContentOptions) state.putAgendaOptions(ready.draft.content)
+ if (WidgetOption.CALENDARS in ready.kind.options) state.putHiddenCalendars(ready.draft.hiddenCalendars)
+ if (ready.kind == WidgetKind.COUNTDOWN) state.putCountdownOptions(ready.draft.countdown)
+ }
+ _events.send(WidgetConfigEvent.SAVED)
+ } catch (e: CancellationException) {
+ throw e
+ } catch (_: IllegalArgumentException) {
+ _state.value = WidgetConfigUiState.Missing
+ } catch (_: Exception) {
+ edit { it.copy(saving = false) }
+ _events.send(WidgetConfigEvent.SAVE_FAILED)
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/widget/countdown/Countdown.kt b/app/src/main/java/de/jeanlucmakiola/calendula/widget/countdown/Countdown.kt
new file mode 100644
index 0000000..b707972
--- /dev/null
+++ b/app/src/main/java/de/jeanlucmakiola/calendula/widget/countdown/Countdown.kt
@@ -0,0 +1,96 @@
+package de.jeanlucmakiola.calendula.widget.countdown
+
+import de.jeanlucmakiola.calendula.domain.EventInstance
+import de.jeanlucmakiola.calendula.domain.isDeclined
+import kotlinx.datetime.DateTimeUnit
+import kotlinx.datetime.TimeZone
+import kotlinx.datetime.atStartOfDayIn
+import kotlinx.datetime.daysUntil
+import kotlinx.datetime.plus
+import kotlinx.datetime.toLocalDateTime
+import kotlin.math.ceil
+import kotlin.math.roundToInt
+import kotlin.time.Duration
+import kotlin.time.Duration.Companion.hours
+import kotlin.time.Duration.Companion.minutes
+import kotlin.time.Instant
+
+/** What a countdown widget counts down to (#439). */
+enum class CountdownMode { NEXT, PINNED }
+
+data class CountdownOptions(
+ val mode: CountdownMode = CountdownMode.NEXT,
+ /** The pinned event or series; a series follows its next occurrence. */
+ val eventId: Long? = null,
+ /** The pinned event's title when it was chosen, for the config screen. */
+ val title: String? = null,
+)
+
+enum class CountdownUnit { NOW, MINUTES, HOURS, DAYS }
+
+/** "in 25 min", "ends in 3 h", "in 12 days": [running] counts to the end instead of the start. */
+data class CountdownLabel(val running: Boolean, val unit: CountdownUnit, val amount: Int)
+
+private fun target(event: EventInstance, now: Instant): Instant = if (event.start <= now) event.end else event.start
+
+/**
+ * The label for [event] at [now]. Under a minute reads "now", under an hour
+ * counts minutes (rounded up), under a day whole hours (rounded), and beyond
+ * that calendar days in [zone], so "in 1 day" is always tomorrow.
+ */
+fun countdownLabel(event: EventInstance, now: Instant, zone: TimeZone): CountdownLabel {
+ val running = event.start <= now
+ val target = target(event, now)
+ val delta = target - now
+ return when {
+ delta < 1.minutes -> CountdownLabel(running, CountdownUnit.NOW, 0)
+ delta < 1.hours -> CountdownLabel(running, CountdownUnit.MINUTES, ceil(delta / 1.minutes).toInt())
+ delta < 24.hours -> CountdownLabel(running, CountdownUnit.HOURS, (delta / 1.hours).roundToInt().coerceAtLeast(1))
+ else -> {
+ val days = now.toLocalDateTime(zone).date.daysUntil(target.toLocalDateTime(zone).date)
+ CountdownLabel(running, CountdownUnit.DAYS, days.coerceAtLeast(1))
+ }
+ }
+}
+
+/**
+ * When [countdownLabel] next reads differently, so the widget redraws then
+ * and not before: every minute in the last hour, at each half hour within the
+ * day, and at midnight or the 24-hour mark beyond that.
+ */
+fun nextLabelChange(event: EventInstance, now: Instant, zone: TimeZone): Instant {
+ val target = target(event, now)
+ val label = countdownLabel(event, now, zone)
+ val lowerBound: Duration = when (label.unit) {
+ CountdownUnit.NOW -> Duration.ZERO
+ CountdownUnit.MINUTES -> (label.amount - 1).minutes
+ CountdownUnit.HOURS -> maxOf((label.amount - 0.5).hours, 1.hours)
+ CountdownUnit.DAYS -> {
+ val midnight = now.toLocalDateTime(zone).date.plus(1, DateTimeUnit.DAY).atStartOfDayIn(zone)
+ return minOf(midnight, target - 24.hours).coerceAtLeast(now + 1.minutes)
+ }
+ }
+ return (target - lowerBound).coerceAtLeast(now + 1.minutes)
+}
+
+/**
+ * The next-event mode's pick: timed, not declined, not over; of those, the one
+ * whose countdown changes soonest — a running event's end, else a start.
+ */
+fun pickNextEvent(instances: List, now: Instant): EventInstance? =
+ instances
+ .filter { !it.isAllDay && !it.isDeclined && it.end > now }
+ .minByOrNull { target(it, now) }
+
+/** A pinned event's occurrence to count to: the running or next one. */
+fun pickPinnedOccurrence(occurrences: List, now: Instant, zone: TimeZone): EventInstance? =
+ occurrences.filter { it.counted(zone).end > now }.minByOrNull { it.start }
+
+/**
+ * The span a countdown counts over. An all-day event is stored on UTC
+ * midnights; it starts and ends at local midnight of those dates instead.
+ */
+fun EventInstance.counted(zone: TimeZone): EventInstance = if (!isAllDay) this else copy(
+ start = start.toLocalDateTime(TimeZone.UTC).date.atStartOfDayIn(zone),
+ end = end.toLocalDateTime(TimeZone.UTC).date.atStartOfDayIn(zone),
+)
diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/widget/countdown/CountdownData.kt b/app/src/main/java/de/jeanlucmakiola/calendula/widget/countdown/CountdownData.kt
new file mode 100644
index 0000000..97c0594
--- /dev/null
+++ b/app/src/main/java/de/jeanlucmakiola/calendula/widget/countdown/CountdownData.kt
@@ -0,0 +1,167 @@
+package de.jeanlucmakiola.calendula.widget.countdown
+
+import android.app.AlarmManager
+import android.app.PendingIntent
+import android.appwidget.AppWidgetManager
+import android.content.BroadcastReceiver
+import android.content.Context
+import android.content.Intent
+import android.os.Build
+import androidx.core.net.toUri
+import androidx.datastore.preferences.core.MutablePreferences
+import androidx.datastore.preferences.core.Preferences
+import androidx.datastore.preferences.core.longPreferencesKey
+import androidx.datastore.preferences.core.stringPreferencesKey
+import androidx.glance.GlanceId
+import androidx.glance.appwidget.GlanceAppWidgetManager
+import de.jeanlucmakiola.calendula.data.prefs.is24Hour
+import de.jeanlucmakiola.calendula.domain.EventInstance
+import de.jeanlucmakiola.calendula.widget.Themed
+import de.jeanlucmakiola.calendula.widget.WidgetLoadContext
+import de.jeanlucmakiola.calendula.widget.hasCalendarPermission
+import de.jeanlucmakiola.calendula.widget.systemZone
+import de.jeanlucmakiola.calendula.widget.today
+import de.jeanlucmakiola.calendula.widget.widgetEntryPoint
+import de.jeanlucmakiola.calendula.widget.widgetLoadContext
+import de.jeanlucmakiola.calendula.widget.widgetState
+import de.jeanlucmakiola.calendula.widget.withoutCalendars
+import de.jeanlucmakiola.floret.android.goAsync
+import de.jeanlucmakiola.floret.glance.updateWithRefresh
+import de.jeanlucmakiola.floret.prefs.toEnum
+import kotlinx.coroutines.flow.first
+import kotlinx.datetime.LocalDate
+import kotlin.time.Clock
+import kotlin.time.Duration.Companion.days
+import kotlin.time.Instant
+
+private val ModeKey = stringPreferencesKey("cfg_countdown_mode")
+private val EventKey = longPreferencesKey("cfg_countdown_event")
+private val TitleKey = stringPreferencesKey("cfg_countdown_title")
+
+internal val Preferences.countdownOptions: CountdownOptions
+ get() = CountdownOptions(this[ModeKey].toEnum(CountdownMode.NEXT), this[EventKey], this[TitleKey])
+
+internal fun MutablePreferences.putCountdownOptions(options: CountdownOptions) {
+ this[ModeKey] = options.mode.name
+ if (options.eventId != null) this[EventKey] = options.eventId else remove(EventKey)
+ if (options.title != null) this[TitleKey] = options.title else remove(TitleKey)
+}
+
+/** How far ahead the next-event mode looks; past this it shows no event. */
+private val NextEventHorizon = 30.days
+
+/** How far ahead a pinned event's next occurrence is looked for. */
+private val PinnedHorizon = 730.days
+
+sealed interface CountdownData {
+ data object NeedsPermission : CountdownData
+ data class Ready(
+ val options: CountdownOptions,
+ /** The occurrence counted to; null when there is none to show. */
+ val event: EventInstance?,
+ val now: Instant,
+ val today: LocalDate,
+ val is24Hour: Boolean,
+ val soften: Boolean,
+ /** The placed widget, for opening its settings; null in a preview. */
+ val appWidgetId: Int?,
+ ) : CountdownData
+}
+
+internal suspend fun Context.loadCountdown(
+ load: WidgetLoadContext,
+ options: CountdownOptions,
+ appWidgetId: Int?,
+): Themed {
+ val settings = load.settings
+ if (!hasCalendarPermission()) return Themed(CountdownData.NeedsPermission, settings.themeMode)
+ val now = Clock.System.now()
+ val repository = widgetEntryPoint().calendarRepository()
+ val event = when (options.mode) {
+ CountdownMode.NEXT ->
+ pickNextEvent(repository.instances(now..now + NextEventHorizon).first().withoutCalendars(load.hiddenCalendars), now)
+ CountdownMode.PINNED -> options.eventId?.let { id ->
+ pickPinnedOccurrence(repository.eventOccurrences(id, now - 1.days..now + PinnedHorizon), now, systemZone())
+ }
+ }
+ return Themed(
+ CountdownData.Ready(
+ options = options,
+ event = event,
+ now = now,
+ today = today(systemZone()),
+ is24Hour = settings.timeFormat.is24Hour(android.text.format.DateFormat.is24HourFormat(this)),
+ soften = settings.soften,
+ appWidgetId = appWidgetId,
+ ),
+ settings.themeMode,
+ )
+}
+
+/** [loadCountdown] for placed widget [id], arming its redraw for when the label next changes. */
+internal suspend fun Context.loadPlacedCountdown(id: GlanceId): Themed {
+ val appWidgetId = GlanceAppWidgetManager(this).getAppWidgetId(id)
+ val loaded = loadCountdown(widgetLoadContext(id), widgetState(id).countdownOptions, appWidgetId)
+ val event = (loaded.content as? CountdownData.Ready)?.event
+ if (event == null) {
+ CountdownTicks.cancel(this, appWidgetId)
+ } else {
+ val zone = systemZone()
+ CountdownTicks.schedule(this, appWidgetId, nextLabelChange(event.counted(zone), (loaded.content as CountdownData.Ready).now, zone))
+ }
+ return loaded
+}
+
+/**
+ * One alarm per countdown widget, for the moment its label changes. Not a
+ * wake-up alarm: with the screen off nobody sees the widget, so the redraw
+ * waits for the device to wake. Exact where allowed (the app holds the exact
+ * alarm permission for reminders), else within a minute.
+ */
+internal object CountdownTicks {
+ private const val ACTION_TICK = "de.jeanlucmakiola.calendula.widget.COUNTDOWN_TICK"
+
+ fun schedule(context: Context, appWidgetId: Int, at: Instant) {
+ val alarms = context.getSystemService(AlarmManager::class.java) ?: return
+ val intent = pendingIntent(context, appWidgetId)
+ val millis = at.toEpochMilliseconds()
+ if (Build.VERSION.SDK_INT < Build.VERSION_CODES.S || alarms.canScheduleExactAlarms()) {
+ alarms.setExact(AlarmManager.RTC, millis, intent)
+ } else {
+ alarms.setWindow(AlarmManager.RTC, millis, 60_000L, intent)
+ }
+ }
+
+ fun cancel(context: Context, appWidgetId: Int) {
+ context.getSystemService(AlarmManager::class.java)?.cancel(pendingIntent(context, appWidgetId))
+ }
+
+ private fun pendingIntent(context: Context, appWidgetId: Int): PendingIntent = PendingIntent.getBroadcast(
+ context,
+ 0,
+ Intent(context, CountdownTickReceiver::class.java)
+ .setAction(ACTION_TICK)
+ // Keys the PendingIntent per widget.
+ .setData("calendula://countdown/$appWidgetId".toUri())
+ .putExtra(AppWidgetManager.EXTRA_APPWIDGET_ID, appWidgetId),
+ PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
+ )
+}
+
+/** Reloads the countdown widget whose label just changed; the load arms the next tick. */
+class CountdownTickReceiver : BroadcastReceiver() {
+ override fun onReceive(context: Context, intent: Intent) {
+ val appWidgetId = intent.getIntExtra(AppWidgetManager.EXTRA_APPWIDGET_ID, AppWidgetManager.INVALID_APPWIDGET_ID)
+ if (appWidgetId == AppWidgetManager.INVALID_APPWIDGET_ID) return
+ val appContext = context.applicationContext
+ goAsync {
+ try {
+ val glanceId = GlanceAppWidgetManager(appContext).getGlanceIdBy(appWidgetId)
+ CountdownWidget().updateWithRefresh(appContext, glanceId)
+ } catch (_: IllegalArgumentException) {
+ // The widget is gone.
+ CountdownTicks.cancel(appContext, appWidgetId)
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/widget/countdown/CountdownWidget.kt b/app/src/main/java/de/jeanlucmakiola/calendula/widget/countdown/CountdownWidget.kt
new file mode 100644
index 0000000..7bca448
--- /dev/null
+++ b/app/src/main/java/de/jeanlucmakiola/calendula/widget/countdown/CountdownWidget.kt
@@ -0,0 +1,230 @@
+package de.jeanlucmakiola.calendula.widget.countdown
+
+import de.jeanlucmakiola.calendula.widget.WidgetKind
+import de.jeanlucmakiola.calendula.widget.previewSizeOf
+import de.jeanlucmakiola.calendula.widget.provideSamplePreview
+import android.appwidget.AppWidgetManager
+import android.content.Context
+import android.content.res.Resources
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
+import androidx.datastore.preferences.core.Preferences
+import androidx.glance.GlanceId
+import androidx.glance.GlanceModifier
+import androidx.glance.GlanceTheme
+import androidx.glance.LocalContext
+import androidx.glance.action.Action
+import androidx.glance.action.clickable
+import androidx.glance.appwidget.GlanceAppWidget
+import androidx.glance.appwidget.SizeMode
+import androidx.glance.appwidget.action.actionStartActivity
+import androidx.glance.appwidget.cornerRadius
+import androidx.glance.appwidget.provideContent
+import androidx.glance.background
+import androidx.glance.currentState
+import androidx.glance.layout.Alignment
+import androidx.glance.layout.Box
+import androidx.glance.layout.Column
+import androidx.glance.layout.Row
+import androidx.glance.layout.Spacer
+import androidx.glance.layout.fillMaxHeight
+import androidx.glance.layout.fillMaxSize
+import androidx.glance.layout.padding
+import androidx.glance.layout.width
+import androidx.glance.state.PreferencesGlanceStateDefinition
+import androidx.glance.text.FontWeight
+import androidx.glance.text.Text
+import androidx.glance.text.TextStyle
+import de.jeanlucmakiola.calendula.MainActivity
+import de.jeanlucmakiola.calendula.R
+import de.jeanlucmakiola.calendula.domain.EventInstance
+import de.jeanlucmakiola.calendula.ui.common.CalendarView
+import de.jeanlucmakiola.calendula.ui.common.eventAccent
+import de.jeanlucmakiola.calendula.widget.CalendulaWidgetReceiver
+import de.jeanlucmakiola.calendula.widget.CalendulaWidgetRoot
+import de.jeanlucmakiola.calendula.widget.WidgetAppearance
+import de.jeanlucmakiola.calendula.widget.config.WidgetConfigActivity
+import de.jeanlucmakiola.calendula.widget.formatWidgetTime
+import de.jeanlucmakiola.calendula.widget.relativeDayLabel
+import de.jeanlucmakiola.calendula.widget.systemZone
+import de.jeanlucmakiola.calendula.widget.widgetAppearance
+import de.jeanlucmakiola.calendula.widget.widgetCardColor
+import de.jeanlucmakiola.floret.glance.LocalWidgetBackground
+import de.jeanlucmakiola.floret.glance.WidgetBackground
+import de.jeanlucmakiola.floret.glance.WidgetData
+import de.jeanlucmakiola.floret.glance.WidgetMessage
+import de.jeanlucmakiola.floret.glance.currentRefreshStamp
+import de.jeanlucmakiola.floret.glance.refreshStamp
+import de.jeanlucmakiola.floret.glance.themedColor
+import de.jeanlucmakiola.floret.prefs.ThemeMode
+import kotlinx.datetime.DateTimeUnit
+import kotlinx.datetime.minus
+import kotlinx.datetime.toLocalDateTime
+
+/**
+ * Countdown (#439): to the next timed event, or to a chosen event or series
+ * ("Vacation · in 12 days"). Redraws when its label changes, see
+ * [nextLabelChange] and [CountdownTicks]. Tapping opens the event.
+ */
+class CountdownWidget : GlanceAppWidget() {
+
+ override val stateDefinition = PreferencesGlanceStateDefinition
+
+ override val sizeMode = SizeMode.Single
+
+ override val previewSizeMode = previewSizeOf(WidgetKind.COUNTDOWN)
+
+ override suspend fun providePreview(context: Context, widgetCategory: Int) =
+ provideSamplePreview(context, WidgetKind.COUNTDOWN)
+
+ override suspend fun provideGlance(context: Context, id: GlanceId) {
+ val widgetData = WidgetData(context.loadPlacedCountdown(id), refreshStamp(context, id))
+ provideContent {
+ val loaded = widgetData.current(currentRefreshStamp()) { context.loadPlacedCountdown(id) }
+ CountdownWidgetContent(loaded.content, currentState().widgetAppearance, loaded.appTheme)
+ }
+ }
+}
+
+class CountdownWidgetReceiver : CalendulaWidgetReceiver() {
+ override val glanceAppWidget: GlanceAppWidget = CountdownWidget()
+
+ override fun onDeleted(context: Context, appWidgetIds: IntArray) {
+ super.onDeleted(context, appWidgetIds)
+ appWidgetIds.forEach { CountdownTicks.cancel(context, it) }
+ }
+}
+
+@Composable
+internal fun CountdownWidgetContent(data: CountdownData, appearance: WidgetAppearance, appTheme: ThemeMode) {
+ CalendulaWidgetRoot(appearance, appTheme) {
+ val context = LocalContext.current
+ when (data) {
+ CountdownData.NeedsPermission -> WidgetMessage(context.getString(R.string.widget_needs_permission))
+ is CountdownData.Ready -> {
+ val event = data.event
+ when {
+ event != null -> EventCountdown(event, data)
+ data.options.mode == CountdownMode.PINNED && data.options.eventId == null ->
+ Message(context.getString(R.string.widget_countdown_choose), configAction(context, data.appWidgetId))
+ data.options.mode == CountdownMode.PINNED ->
+ Message(context.getString(R.string.widget_countdown_over, data.options.title.orEmpty()), configAction(context, data.appWidgetId))
+ else -> Message(
+ context.getString(R.string.widget_upcoming_empty),
+ actionStartActivity(MainActivity.openViewIntent(context, null)),
+ )
+ }
+ }
+ }
+ }
+}
+
+private fun configAction(context: Context, appWidgetId: Int?): Action? =
+ appWidgetId?.takeIf { it != AppWidgetManager.INVALID_APPWIDGET_ID }
+ ?.let { actionStartActivity(WidgetConfigActivity.intent(context, it)) }
+
+@Composable
+private fun Message(text: String, action: Action?) {
+ val modifier = if (action != null) GlanceModifier.clickable(action) else GlanceModifier
+ WidgetMessage(text, modifier = modifier)
+}
+
+@Composable
+private fun EventCountdown(event: EventInstance, data: CountdownData.Ready) {
+ val context = LocalContext.current
+ val label = countdownLabel(event.counted(systemZone()), data.now, systemZone())
+ val open = actionStartActivity(
+ MainActivity.openEventIntent(
+ context = context,
+ eventId = event.eventId,
+ beginMillis = event.start.toEpochMilliseconds(),
+ endMillis = event.end.toEpochMilliseconds(),
+ source = CalendarView.Agenda,
+ ),
+ )
+ // Over a wallpaper the text sits on its own card, as every other widget's rows do.
+ val card = if (LocalWidgetBackground.current == WidgetBackground.TRANSPARENT) {
+ GlanceModifier.cornerRadius(16.dp).background(widgetCardColor())
+ } else {
+ GlanceModifier
+ }
+ Box(modifier = GlanceModifier.fillMaxSize().padding(6.dp)) {
+ Row(
+ modifier = card.fillMaxSize().clickable(open).padding(horizontal = 10.dp, vertical = 8.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ Box(
+ modifier = GlanceModifier
+ .width(4.dp)
+ .fillMaxHeight()
+ .cornerRadius(2.dp)
+ .background(
+ themedColor(
+ day = eventAccent(event.color, dark = false, data.soften),
+ night = eventAccent(event.color, dark = true, data.soften),
+ ),
+ ),
+ ) {}
+ Spacer(GlanceModifier.width(10.dp))
+ Column(modifier = GlanceModifier.defaultWeight(), verticalAlignment = Alignment.CenterVertically) {
+ Text(
+ text = event.title.ifBlank { context.getString(R.string.event_untitled) },
+ maxLines = 1,
+ style = TextStyle(color = GlanceTheme.colors.onSurface, fontSize = 14.sp, fontWeight = FontWeight.Medium),
+ )
+ Text(
+ text = countdownText(context.resources, label),
+ maxLines = 1,
+ style = TextStyle(color = GlanceTheme.colors.primary, fontSize = 22.sp, fontWeight = FontWeight.Bold),
+ )
+ Text(
+ text = whenText(context, event, data),
+ maxLines = 1,
+ style = TextStyle(color = GlanceTheme.colors.onSurfaceVariant, fontSize = 12.sp),
+ )
+ }
+ }
+ }
+}
+
+internal fun countdownText(res: Resources, label: CountdownLabel): String = when (label.unit) {
+ CountdownUnit.NOW -> res.getString(if (label.running) R.string.widget_countdown_ending_now else R.string.widget_countdown_now)
+ CountdownUnit.MINUTES -> res.getQuantityString(
+ if (label.running) R.plurals.widget_countdown_ends_minutes else R.plurals.widget_countdown_in_minutes,
+ label.amount,
+ label.amount,
+ )
+ CountdownUnit.HOURS -> res.getQuantityString(
+ if (label.running) R.plurals.widget_countdown_ends_hours else R.plurals.widget_countdown_in_hours,
+ label.amount,
+ label.amount,
+ )
+ CountdownUnit.DAYS -> when {
+ label.amount == 1 && label.running -> res.getString(R.string.widget_countdown_ends_tomorrow)
+ label.amount == 1 -> res.getString(R.string.widget_countdown_tomorrow)
+ else -> res.getQuantityString(
+ if (label.running) R.plurals.widget_countdown_ends_days else R.plurals.widget_countdown_in_days,
+ label.amount,
+ label.amount,
+ )
+ }
+}
+
+/** When it starts, or ends once running: "Today · 14:00", "Fri · All day". */
+private fun whenText(context: Context, event: EventInstance, data: CountdownData.Ready): String {
+ val counted = event.counted(systemZone())
+ val running = counted.start <= data.now
+ val at = if (running) counted.end else counted.start
+ val date = at.toLocalDateTime(systemZone()).date
+ val day = relativeDayLabel(context, date, data.today)
+ return when {
+ event.isAllDay && running -> context.getString(
+ R.string.widget_countdown_until,
+ relativeDayLabel(context, date.minus(1, DateTimeUnit.DAY), data.today),
+ )
+ event.isAllDay -> "$day · ${context.getString(R.string.event_detail_all_day)}"
+ running -> context.getString(R.string.agenda_span_ends, "$day · ${formatWidgetTime(at, data.is24Hour)}")
+ else -> "$day · ${formatWidgetTime(at, data.is24Hour)}"
+ }
+}
diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/widget/date/DateWidget.kt b/app/src/main/java/de/jeanlucmakiola/calendula/widget/date/DateWidget.kt
new file mode 100644
index 0000000..e75120f
--- /dev/null
+++ b/app/src/main/java/de/jeanlucmakiola/calendula/widget/date/DateWidget.kt
@@ -0,0 +1,130 @@
+package de.jeanlucmakiola.calendula.widget.date
+
+import de.jeanlucmakiola.calendula.widget.WidgetKind
+import de.jeanlucmakiola.calendula.widget.previewSizeOf
+import de.jeanlucmakiola.calendula.widget.provideSamplePreview
+import android.content.Context
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
+import androidx.datastore.preferences.core.Preferences
+import androidx.glance.GlanceId
+import androidx.glance.GlanceModifier
+import androidx.glance.GlanceTheme
+import androidx.glance.LocalContext
+import androidx.glance.LocalSize
+import androidx.glance.action.clickable
+import androidx.glance.appwidget.GlanceAppWidget
+import androidx.glance.appwidget.SizeMode
+import androidx.glance.appwidget.action.actionStartActivity
+import androidx.glance.appwidget.provideContent
+import androidx.glance.currentState
+import androidx.glance.layout.Alignment
+import androidx.glance.layout.Column
+import androidx.glance.layout.Spacer
+import androidx.glance.layout.fillMaxSize
+import androidx.glance.layout.height
+import androidx.glance.layout.padding
+import androidx.glance.semantics.contentDescription
+import androidx.glance.semantics.semantics
+import androidx.glance.state.PreferencesGlanceStateDefinition
+import androidx.glance.text.FontWeight
+import androidx.glance.text.Text
+import androidx.glance.text.TextAlign
+import androidx.glance.text.TextStyle
+import de.jeanlucmakiola.calendula.MainActivity
+import de.jeanlucmakiola.calendula.ui.common.CalendarView
+import de.jeanlucmakiola.calendula.widget.CalendulaWidgetReceiver
+import de.jeanlucmakiola.calendula.widget.CalendulaWidgetRoot
+import de.jeanlucmakiola.calendula.widget.WidgetAppearance
+import de.jeanlucmakiola.calendula.widget.loadToday
+import de.jeanlucmakiola.calendula.widget.widgetAppearance
+import de.jeanlucmakiola.calendula.widget.widgetLocale
+import de.jeanlucmakiola.floret.glance.LocalWidgetBackground
+import de.jeanlucmakiola.floret.glance.WidgetBackground
+import de.jeanlucmakiola.floret.glance.WidgetData
+import de.jeanlucmakiola.floret.glance.WidgetShape
+import de.jeanlucmakiola.floret.glance.WidgetShapedBox
+import de.jeanlucmakiola.floret.glance.currentRefreshStamp
+import de.jeanlucmakiola.floret.glance.refreshStamp
+import de.jeanlucmakiola.floret.glance.wallpaperBacking
+import de.jeanlucmakiola.floret.locale.localizedDateFormatter
+import de.jeanlucmakiola.floret.prefs.ThemeMode
+import kotlinx.datetime.LocalDate
+import kotlinx.datetime.toJavaDayOfWeek
+import kotlinx.datetime.toJavaLocalDate
+import java.time.format.TextStyle as JavaTextStyle
+
+/** 1×1 date (#376): today's weekday above its number on an expressive shape; opens today. */
+class DateWidget : GlanceAppWidget() {
+
+ override val stateDefinition = PreferencesGlanceStateDefinition
+
+ override val sizeMode = SizeMode.Exact
+
+ override val previewSizeMode = previewSizeOf(WidgetKind.DATE)
+
+ override suspend fun providePreview(context: Context, widgetCategory: Int) =
+ provideSamplePreview(context, WidgetKind.DATE)
+
+ override suspend fun provideGlance(context: Context, id: GlanceId) {
+ val widgetData = WidgetData(context.loadToday(), refreshStamp(context, id))
+ provideContent {
+ val loaded = widgetData.current(currentRefreshStamp()) { context.loadToday() }
+ DateWidgetContent(loaded.content, currentState().widgetAppearance, loaded.appTheme)
+ }
+ }
+}
+
+class DateWidgetReceiver : CalendulaWidgetReceiver() {
+ override val glanceAppWidget: GlanceAppWidget = DateWidget()
+}
+
+private val WeekdayLine = 18.dp
+
+@Composable
+internal fun DateWidgetContent(today: LocalDate, appearance: WidgetAppearance, appTheme: ThemeMode) {
+ CalendulaWidgetRoot(appearance, appTheme) {
+ val context = LocalContext.current
+ val size = LocalSize.current
+ val fontScale = context.resources.configuration.fontScale
+ val side = (minOf(size.width, size.height - WeekdayLine * fontScale) - 8.dp).coerceIn(32.dp, 120.dp)
+ val label = localizedDateFormatter(widgetLocale(), "EEEEdMMMM").format(today.toJavaLocalDate())
+ val weekday = TextStyle(
+ color = GlanceTheme.colors.onSurface,
+ fontSize = 12.sp,
+ fontWeight = FontWeight.Medium,
+ textAlign = TextAlign.Center,
+ )
+ Column(
+ modifier = GlanceModifier
+ .fillMaxSize()
+ .clickable(actionStartActivity(MainActivity.openDateIntent(context, today, CalendarView.Day)))
+ .semantics { contentDescription = label },
+ horizontalAlignment = Alignment.CenterHorizontally,
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ val name = today.dayOfWeek.toJavaDayOfWeek().getDisplayName(JavaTextStyle.SHORT, widgetLocale())
+ val backing = if (LocalWidgetBackground.current == WidgetBackground.TRANSPARENT) {
+ GlanceModifier.wallpaperBacking(radius = 8.dp).padding(horizontal = 6.dp)
+ } else {
+ GlanceModifier
+ }
+ Text(text = name.uppercase(widgetLocale()), maxLines = 1, style = weekday, modifier = backing)
+ Spacer(GlanceModifier.height(2.dp))
+ WidgetShapedBox(shape = WidgetShape.COOKIE, color = GlanceTheme.colors.primary, size = side) {
+ Text(
+ text = today.day.toString(),
+ maxLines = 1,
+ style = TextStyle(
+ color = GlanceTheme.colors.onPrimary,
+ // Sized to the shape, so held against the font scale.
+ fontSize = (side.value * 0.45f / fontScale).sp,
+ fontWeight = FontWeight.Bold,
+ textAlign = TextAlign.Center,
+ ),
+ )
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/widget/month/MonthGrid.kt b/app/src/main/java/de/jeanlucmakiola/calendula/widget/month/MonthGrid.kt
new file mode 100644
index 0000000..e43d95c
--- /dev/null
+++ b/app/src/main/java/de/jeanlucmakiola/calendula/widget/month/MonthGrid.kt
@@ -0,0 +1,88 @@
+package de.jeanlucmakiola.calendula.widget.month
+
+import androidx.compose.ui.unit.Dp
+import de.jeanlucmakiola.calendula.domain.EventInstance
+import de.jeanlucmakiola.calendula.ui.month.DaySeating
+import de.jeanlucmakiola.calendula.ui.month.MonthWeek
+import de.jeanlucmakiola.calendula.ui.month.seating
+import de.jeanlucmakiola.calendula.ui.month.spanAt
+
+/** A cell [cols] columns wide holding [item], or a merged gap where [item] is null. */
+internal data class GridCell(val item: T?, val cols: Int)
+
+/**
+ * The seven columns as cells and the gaps between them, consecutive empty
+ * columns merged into one: the day-open taps come from the strip underneath,
+ * so a gap needs no per-column view. [cellAt] gives what starts at a column
+ * and how many columns it covers.
+ */
+internal fun mergeGaps(columns: Int, cellAt: (Int) -> GridCell?): List> {
+ val cells = mutableListOf>()
+ var gap = 0
+ var col = 0
+ while (col < 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. [showsOverflow] is off when
+ * the week has no room for even the "+N" row.
+ */
+internal class WeekLayout(val week: MonthWeek, val laneCap: Int, val showsOverflow: Boolean = true) {
+ private val seating: List = week.days.indices.map { week.seating(it, laneCap) }
+
+ /** Lanes filled from the top; empty lanes below are not drawn. */
+ 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(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> = 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) } }
+ }
+}
+
+/**
+ * Event lanes a week row [weekHeight] tall holds under its day number, held to
+ * [MAX_LANES] so a tall widget's payload stays bounded (#214).
+ */
+internal fun lanesFitting(weekHeight: Dp): Int =
+ ((weekHeight.value - DAY_ROW_DP - LANES_TOP_DP) / LANE_ROW_DP).toInt().coerceIn(0, MAX_LANES)
+
+/**
+ * Seats each week in [lanes] lanes. A week whose events need more gives its
+ * last lane to the "+N" row instead.
+ */
+internal fun fitWeeks(weeks: List, lanes: Int): List = weeks.map { week ->
+ if (lanes == 0) return@map WeekLayout(week, laneCap = 0, showsOverflow = false)
+ val needed = WeekLayout(week, UNCAPPED_LANES).usedLanes
+ WeekLayout(week, if (needed <= lanes) lanes else lanes - 1)
+}
+
+/** Lanes a week shows at most, however tall the widget. */
+internal const val MAX_LANES = 8
+
+private const val DAY_ROW_DP = 18f
+private const val LANES_TOP_DP = 2f
+private const val LANE_ROW_DP = 15f
+private const val UNCAPPED_LANES = 64
diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/widget/month/MonthGridViews.kt b/app/src/main/java/de/jeanlucmakiola/calendula/widget/month/MonthGridViews.kt
new file mode 100644
index 0000000..e8aaa41
--- /dev/null
+++ b/app/src/main/java/de/jeanlucmakiola/calendula/widget/month/MonthGridViews.kt
@@ -0,0 +1,169 @@
+package de.jeanlucmakiola.calendula.widget.month
+
+import android.app.PendingIntent
+import android.content.Context
+import android.graphics.Typeface
+import android.text.SpannableString
+import android.text.Spanned
+import android.text.style.StrikethroughSpan
+import android.text.style.StyleSpan
+import android.view.View
+import android.widget.RemoteViews
+import androidx.compose.ui.graphics.Color
+import de.jeanlucmakiola.calendula.MainActivity
+import de.jeanlucmakiola.calendula.R
+import de.jeanlucmakiola.calendula.domain.EventInstance
+import de.jeanlucmakiola.calendula.domain.isDeclined
+import de.jeanlucmakiola.calendula.ui.common.CalendarView
+import de.jeanlucmakiola.calendula.ui.common.eventFill
+import de.jeanlucmakiola.calendula.ui.common.eventInk
+import de.jeanlucmakiola.floret.glance.RemoteColor
+import de.jeanlucmakiola.floret.glance.WidgetThemeMode
+import de.jeanlucmakiola.floret.glance.remoteColor
+import de.jeanlucmakiola.floret.glance.setImageTint
+import de.jeanlucmakiola.floret.glance.setTextColor
+import kotlinx.datetime.LocalDate
+import kotlinx.datetime.Month
+
+/** The theme colours the plain-RemoteViews grid draws with. */
+internal data class GridColors(
+ val onSurface: RemoteColor,
+ val onSurfaceVariant: RemoteColor,
+ val primary: RemoteColor,
+ val onPrimary: RemoteColor,
+ val cell: RemoteColor,
+)
+
+/**
+ * The month's week rows as one plain RemoteViews tree, embedded in the Glance
+ * widget so its views don't draw on Glance's view-id pool (#372): every event
+ * gets its bar, and only what can't fit the widget's height folds into "+N".
+ *
+ * A host may reapply a new rendering onto the views of the last one rather than
+ * inflate it afresh, so every container is cleared before it's filled and every
+ * view state is set both ways, never just when it differs from the layout.
+ */
+internal fun monthGridViews(
+ context: Context,
+ layouts: List,
+ currentMonth: Month,
+ today: LocalDate,
+ soften: Boolean,
+ colors: GridColors,
+ mode: WidgetThemeMode,
+): RemoteViews {
+ val grid = RemoteViews(context.packageName, R.layout.widget_month_grid)
+ grid.removeAllViews(R.id.month_grid)
+ layouts.forEach { grid.addView(R.id.month_grid, weekViews(context, it, currentMonth, today, soften, colors, mode)) }
+ return grid
+}
+
+private fun weekViews(
+ context: Context,
+ layout: WeekLayout,
+ currentMonth: Month,
+ today: LocalDate,
+ soften: Boolean,
+ colors: GridColors,
+ mode: WidgetThemeMode,
+): RemoteViews {
+ val week = RemoteViews(context.packageName, R.layout.widget_month_week)
+ week.removeAllViews(R.id.week_lanes)
+ layout.week.days.forEachIndexed { col, date ->
+ week.setOnClickPendingIntent(STRIP_IDS[col], openDayIntent(context, date))
+ // Each day its own rounded cell, as in the app's month grid. Days around
+ // the month get a shade over their (opaque) cell, so they recede without
+ // letting a wallpaper show through.
+ week.setImageTint(context, CELL_IDS[col], colors.cell)
+ val outside = date.month != currentMonth
+ week.setViewVisibility(CELL_DIM_IDS[col], if (outside) View.VISIBLE else View.GONE)
+ if (outside) {
+ week.setImageTint(context, CELL_DIM_IDS[col], mode.remoteColor(OUTSIDE_SHADE_DAY, OUTSIDE_SHADE_NIGHT))
+ }
+ val dayId = DAY_IDS[col]
+ val isToday = date == today
+ week.setTextViewText(dayId, if (isToday) bold(date.day.toString()) else date.day.toString())
+ week.setTextColor(context, dayId, when {
+ isToday -> colors.onPrimary
+ date.month == currentMonth -> colors.onSurface
+ else -> colors.onSurfaceVariant
+ })
+ week.setViewVisibility(TODAY_IDS[col], if (isToday) View.VISIBLE else View.GONE)
+ if (isToday) week.setImageTint(context, TODAY_IDS[col], colors.primary)
+ }
+ layout.lanes.forEach { cells ->
+ val lane = laneViews(context)
+ cells.forEach { cell ->
+ val event = cell.item
+ lane.addView(R.id.lane, if (event == null) gap(context, cell.cols) else bar(context, event, cell.cols, soften, mode))
+ }
+ week.addView(R.id.week_lanes, lane)
+ }
+ if (layout.showsOverflow && layout.overflow.isNotEmpty()) {
+ val lane = laneViews(context)
+ layout.overflow.forEach { cell ->
+ val count = cell.item
+ if (count == null) {
+ lane.addView(R.id.lane, gap(context, cell.cols))
+ } else {
+ val more = RemoteViews(context.packageName, R.layout.widget_month_more)
+ more.setTextViewText(R.id.more, "+$count")
+ more.setTextColor(context, R.id.more, colors.onSurfaceVariant)
+ lane.addView(R.id.lane, more)
+ }
+ }
+ week.addView(R.id.week_lanes, lane)
+ }
+ return week
+}
+
+private fun laneViews(context: Context) =
+ RemoteViews(context.packageName, R.layout.widget_month_lane).apply { removeAllViews(R.id.lane) }
+
+private fun gap(context: Context, cols: Int) = RemoteViews(context.packageName, GAP_LAYOUTS[cols - 1])
+
+private fun bar(context: Context, event: EventInstance, cols: Int, soften: Boolean, mode: WidgetThemeMode): RemoteViews {
+ val views = RemoteViews(context.packageName, BAR_LAYOUTS[cols - 1])
+ val dayFill = eventFill(event.color, dark = false, soften)
+ val nightFill = eventFill(event.color, dark = true, soften)
+ views.setImageTint(context, R.id.bar_bg, mode.remoteColor(dayFill, nightFill))
+ val title = event.title.ifBlank { context.getString(R.string.event_untitled) }
+ // A span, like Glance's own decoration: paint flags would drop the text's defaults.
+ views.setTextViewText(R.id.bar_title, if (event.isDeclined) span(title, StrikethroughSpan()) else title)
+ views.setTextColor(context, R.id.bar_title, mode.remoteColor(eventInk(dayFill), eventInk(nightFill)))
+ return views
+}
+
+private fun bold(text: String): CharSequence = span(text, StyleSpan(Typeface.BOLD))
+
+private fun span(text: String, span: Any): CharSequence =
+ SpannableString(text).apply { setSpan(span, 0, length, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE) }
+
+private fun openDayIntent(context: Context, date: LocalDate): PendingIntent = PendingIntent.getActivity(
+ context,
+ date.toEpochDays().toInt(),
+ MainActivity.openDateIntent(context, date, CalendarView.Month),
+ PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT,
+)
+
+private val STRIP_IDS = intArrayOf(R.id.strip_0, R.id.strip_1, R.id.strip_2, R.id.strip_3, R.id.strip_4, R.id.strip_5, R.id.strip_6)
+/** The shade over a day outside the month: a dark cell needs more of it to read as dimmed. */
+private val OUTSIDE_SHADE_DAY = Color.Black.copy(alpha = 0.18f)
+private val OUTSIDE_SHADE_NIGHT = Color.Black.copy(alpha = 0.50f)
+
+private val CELL_DIM_IDS = intArrayOf(
+ R.id.cell_dim_0, R.id.cell_dim_1, R.id.cell_dim_2, R.id.cell_dim_3, R.id.cell_dim_4, R.id.cell_dim_5, R.id.cell_dim_6,
+)
+private val CELL_IDS = intArrayOf(R.id.cell_0, R.id.cell_1, R.id.cell_2, R.id.cell_3, R.id.cell_4, R.id.cell_5, R.id.cell_6)
+private val DAY_IDS = intArrayOf(R.id.day_0, R.id.day_1, R.id.day_2, R.id.day_3, R.id.day_4, R.id.day_5, R.id.day_6)
+private val TODAY_IDS = intArrayOf(
+ R.id.today_bg_0, R.id.today_bg_1, R.id.today_bg_2, R.id.today_bg_3, R.id.today_bg_4, R.id.today_bg_5, R.id.today_bg_6,
+)
+private val BAR_LAYOUTS = intArrayOf(
+ R.layout.widget_month_bar_1, R.layout.widget_month_bar_2, R.layout.widget_month_bar_3, R.layout.widget_month_bar_4,
+ R.layout.widget_month_bar_5, R.layout.widget_month_bar_6, R.layout.widget_month_bar_7,
+)
+private val GAP_LAYOUTS = intArrayOf(
+ R.layout.widget_month_gap_1, R.layout.widget_month_gap_2, R.layout.widget_month_gap_3, R.layout.widget_month_gap_4,
+ R.layout.widget_month_gap_5, R.layout.widget_month_gap_6, R.layout.widget_month_gap_7,
+)
diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/widget/month/MonthWidget.kt b/app/src/main/java/de/jeanlucmakiola/calendula/widget/month/MonthWidget.kt
index c72e851..f45af96 100644
--- a/app/src/main/java/de/jeanlucmakiola/calendula/widget/month/MonthWidget.kt
+++ b/app/src/main/java/de/jeanlucmakiola/calendula/widget/month/MonthWidget.kt
@@ -1,76 +1,83 @@
package de.jeanlucmakiola.calendula.widget.month
-import de.jeanlucmakiola.calendula.domain.isDeclined
-import de.jeanlucmakiola.calendula.widget.glanceDeclinedDecoration
+import de.jeanlucmakiola.calendula.widget.WidgetKind
+import de.jeanlucmakiola.calendula.widget.previewSizeOf
+import de.jeanlucmakiola.calendula.widget.provideSamplePreview
import android.content.Context
-import android.content.res.Configuration
import androidx.compose.runtime.Composable
-import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.SideEffect
import androidx.compose.runtime.getValue
-import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.intPreferencesKey
-import androidx.glance.ColorFilter
import androidx.glance.GlanceId
import androidx.glance.GlanceModifier
import androidx.glance.GlanceTheme
-import androidx.glance.Image
import androidx.glance.ImageProvider
import androidx.glance.LocalContext
import androidx.glance.LocalSize
import androidx.glance.action.ActionParameters
import androidx.glance.action.actionParametersOf
-import androidx.glance.action.clickable
import androidx.glance.appwidget.GlanceAppWidget
+import androidx.glance.appwidget.AndroidRemoteViews
+import de.jeanlucmakiola.floret.glance.WidgetBackground
+import de.jeanlucmakiola.floret.glance.LocalWidgetBackground
+import androidx.glance.background
+import androidx.glance.appwidget.cornerRadius
import androidx.glance.appwidget.SizeMode
import androidx.glance.appwidget.action.ActionCallback
import androidx.glance.appwidget.action.actionRunCallback
import androidx.glance.appwidget.action.actionStartActivity
-import androidx.glance.appwidget.cornerRadius
import androidx.glance.appwidget.provideContent
import androidx.glance.appwidget.state.getAppWidgetState
import androidx.glance.appwidget.state.updateAppWidgetState
-import androidx.glance.background
import androidx.glance.currentState
import androidx.glance.layout.Alignment
import androidx.glance.layout.Box
import androidx.glance.layout.Column
import androidx.glance.layout.Row
import androidx.glance.layout.Spacer
-import androidx.glance.layout.fillMaxHeight
import androidx.glance.layout.fillMaxSize
import androidx.glance.layout.fillMaxWidth
import androidx.glance.layout.height
import androidx.glance.layout.padding
import androidx.glance.layout.size
-import androidx.glance.layout.width
import androidx.glance.state.PreferencesGlanceStateDefinition
-import androidx.glance.text.FontWeight
+import de.jeanlucmakiola.floret.glance.LocalWidgetThemeMode
+import de.jeanlucmakiola.floret.glance.WidgetColorRole
+import de.jeanlucmakiola.floret.glance.remoteColor
+import de.jeanlucmakiola.floret.glance.WidgetActionStyle
+import de.jeanlucmakiola.floret.glance.WidgetHeader
+import de.jeanlucmakiola.floret.glance.WidgetHeaderAction
+import de.jeanlucmakiola.floret.glance.WidgetHeaderActionGap
+import de.jeanlucmakiola.floret.glance.WidgetHeaderTextAction
import androidx.glance.text.Text
import androidx.glance.text.TextAlign
import androidx.glance.text.TextStyle
-import androidx.glance.unit.ColorProvider
import de.jeanlucmakiola.calendula.MainActivity
import de.jeanlucmakiola.calendula.R
-import de.jeanlucmakiola.calendula.domain.EventInstance
import de.jeanlucmakiola.calendula.ui.common.CalendarView
import de.jeanlucmakiola.calendula.ui.common.formatCalendarTitle
-import de.jeanlucmakiola.calendula.ui.month.MonthWeek
import de.jeanlucmakiola.calendula.ui.month.layoutMonthWeeks
-import de.jeanlucmakiola.calendula.ui.common.eventFill
-import de.jeanlucmakiola.calendula.ui.common.eventInk
-import de.jeanlucmakiola.calendula.widget.CalendulaGlanceTheme
+import de.jeanlucmakiola.calendula.widget.CalendulaWidgetRoot
+import de.jeanlucmakiola.calendula.widget.WidgetAppearance
+import de.jeanlucmakiola.calendula.widget.widgetAppearance
+import de.jeanlucmakiola.floret.glance.wallpaperBacking
+import de.jeanlucmakiola.floret.prefs.ThemeMode
import de.jeanlucmakiola.calendula.widget.MonthWidgetSource
-import de.jeanlucmakiola.calendula.widget.loadMonthWidgetSource
+import de.jeanlucmakiola.calendula.widget.loadCachedMonthSource
+import de.jeanlucmakiola.calendula.widget.monthWindowCenter
+import de.jeanlucmakiola.floret.glance.WidgetData
+import de.jeanlucmakiola.floret.glance.currentRefreshStamp
+import de.jeanlucmakiola.floret.glance.refreshStamp
+import de.jeanlucmakiola.floret.glance.updateWithRefresh
import de.jeanlucmakiola.calendula.widget.monthIndexOf
import de.jeanlucmakiola.calendula.widget.systemZone
import de.jeanlucmakiola.calendula.widget.today
-import de.jeanlucmakiola.calendula.widget.widgetEntryPoint
-import kotlinx.coroutines.flow.first
import androidx.compose.ui.unit.Dp
import kotlinx.datetime.DayOfWeek
import kotlinx.datetime.LocalDate
@@ -83,12 +90,16 @@ import java.util.Locale
/** Per-widget state: the displayed month as `year * 12 + monthOrdinal`. */
private val MONTH_INDEX_KEY = intPreferencesKey("month_index")
-/** Event rows (lanes) shown per week before the rest collapse into "+N". */
-private const val MAX_LANES = 3
-private val LANE_HEIGHT = 14.dp
-private val DAY_NUMBER_HEIGHT = 18.dp
private val GRID_HPADDING = 8.dp
+/**
+ * Height around the week rows: body padding, the header's bottom padding and
+ * spacer and the weekday pills' padding, then the header (its 28dp pills, or a title line in its transparent
+ * pill when that's taller) and the weekday row, the text lines at [fontScale].
+ */
+private fun gridChromeHeight(fontScale: Float): Dp =
+ 21.dp + maxOf(28.dp, 19.dp * fontScale + 8.dp) + 15.dp * fontScale
+
private fun currentMonthIndex(zone: TimeZone): Int = monthIndexOf(today(zone))
private fun yearMonthOf(index: Int): YearMonth =
@@ -99,9 +110,9 @@ private fun yearMonthOf(index: Int): YearMonth =
* event bars and titled single-day pills (the in-app lane layout via
* [layoutMonthWeeks]), and prev/next/today navigation.
*
- * Columns are sized explicitly from [LocalSize] (hence [SizeMode.Exact]) so a
- * multi-day span renders as a single Box spanning its columns — connected, no
- * inter-cell seam, with rounded end caps. The displayed month lives in Glance
+ * The week rows are one plain RemoteViews tree ([monthGridViews]) embedded in
+ * the Glance widget, so a busy month isn't held to Glance's view-id pool (#372);
+ * the lanes each week shows come from the widget's height. The displayed month lives in Glance
* state and is read reactively in the composition ([currentState]) so the arrows
* move it via plain recomposition, not a (here-unreliable) widget session reload.
*
@@ -112,7 +123,7 @@ private fun yearMonthOf(index: Int): YearMonth =
* the launcher rebinds. [SizeMode.Exact] serialises the grid once per size the
* host reports, so the ceiling arrives at half the view count you would expect: a
* grid that emitted a view per cell blew past it after three taps (#214). Every
- * row here emits only what it fills.
+ * row here emits only what it fills, and lanes stop at what the height shows.
*/
class MonthWidget : GlanceAppWidget() {
@@ -122,27 +133,60 @@ class MonthWidget : GlanceAppWidget() {
* Exact rather than [SizeMode.Single] or [SizeMode.Responsive], both of which
* return early from `GlanceAppWidget.resize` — under those the grid is never
* recomposed for a new size and a resized widget clips instead of reflowing.
- * Exact costs one serialised copy per host size, which is affordable now that
- * a rendering is ~190 views rather than ~740.
+ * Exact costs one serialised copy per host size, so the grid draws only the
+ * lanes the size can show, folding the rest into "+N".
*/
override val sizeMode = SizeMode.Exact
+ override val previewSizeMode = previewSizeOf(WidgetKind.MONTH)
+
+ override suspend fun providePreview(context: Context, widgetCategory: Int) =
+ provideSamplePreview(context, WidgetKind.MONTH)
+
override suspend fun provideGlance(context: Context, id: GlanceId) {
val shown = getAppWidgetState(context, id)[MONTH_INDEX_KEY]
- val source = context.loadMonthWidgetSource(shown)
- val dark = (context.resources.configuration.uiMode and
- Configuration.UI_MODE_NIGHT_MASK) == Configuration.UI_MODE_NIGHT_YES
- // Read fresh (not through the cached source) so toggling the softener
- // redraws with the new choice; it's one cheap DataStore read.
- val soften = context.widgetEntryPoint().settingsPrefs().softenCalendarColors.first()
+ ?: currentMonthIndex(systemZone())
+ val stamp = refreshStamp(context, id)
+ val initialCenter = monthWindowCenter(shown)
+ // Shared by every size Exact composes, so each loads nothing of its own.
+ val widgetData = WidgetData(context.loadCachedMonthSource(id, initialCenter, stamp), initialCenter, stamp)
provideContent {
- CalendulaGlanceTheme {
- MonthWidgetBody(source = source, dark = dark, soften = soften)
+ 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 loaded = widgetData.current(center, currentStamp) {
+ context.loadCachedMonthSource(id, center, currentStamp)
}
+ val source = loaded.content
+ // A jump out of the loaded window keeps the last month it covers
+ // until the reload lands, rather than drawing an empty grid.
+ var lastShown by remember { mutableIntStateOf(index) }
+ 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 }
+ MonthWidgetContent(
+ source = source,
+ index = shown,
+ appearance = currentState().widgetAppearance,
+ appTheme = loaded.appTheme,
+ )
}
}
}
+/** The whole month widget showing month [index], shared with its config screen's preview (#292). */
+@Composable
+internal fun MonthWidgetContent(source: MonthWidgetSource, index: Int, appearance: WidgetAppearance, appTheme: ThemeMode) {
+ CalendulaWidgetRoot(appearance, appTheme) {
+ MonthWidgetBody(source = source, index = index)
+ }
+}
+
/**
* Step the displayed month by the `delta` action parameter (±1).
*
@@ -179,57 +223,53 @@ class ShiftMonthAction : ActionCallback {
}
}
-/** Jump the displayed month back to the current month. See [ShiftMonthAction]. */
+/** Jump the displayed month back to the current month, reloading it. See [ShiftMonthAction]. */
class ResetMonthAction : ActionCallback {
override suspend fun onAction(context: Context, glanceId: GlanceId, parameters: ActionParameters) {
updateAppWidgetState(context, glanceId) { prefs -> prefs.remove(MONTH_INDEX_KEY) }
- MonthWidget().update(context.applicationContext, glanceId)
+ MonthWidget().updateWithRefresh(context.applicationContext, glanceId)
}
}
@Composable
-private fun MonthWidgetBody(source: MonthWidgetSource, dark: Boolean, soften: Boolean) {
+private fun MonthWidgetBody(source: MonthWidgetSource, index: Int) {
Column(
modifier = GlanceModifier
.fillMaxSize()
- .background(GlanceTheme.colors.surface)
.padding(horizontal = GRID_HPADDING, vertical = 6.dp),
) {
when (source) {
MonthWidgetSource.NeedsPermission -> {
- MonthHeader(label = "Calendula")
+ MonthHeader(label = "Calendula", today = today(systemZone()))
PermissionMessage()
}
is MonthWidgetSource.Ready -> {
val zone = systemZone()
- val index = currentState(MONTH_INDEX_KEY) ?: currentMonthIndex(zone)
val ym = yearMonthOf(index)
- // Paging past the loaded months reloads around the new one in a
- // live session, where provideGlance doesn't run again.
+ val weeks = layoutMonthWeeks(ym, source.weekStart, source.instances, zone)
val context = LocalContext.current
- var loaded by remember(source) { mutableStateOf(source) }
- LaunchedEffect(index) {
- if (index !in loaded.months) {
- (context.loadMonthWidgetSource(index) as? MonthWidgetSource.Ready)
- ?.let { loaded = it }
- }
- }
- // Column width from the live widget size, minus our H padding.
- val colW = (LocalSize.current.width - GRID_HPADDING * 2) / 7
- val weeks = layoutMonthWeeks(ym, loaded.weekStart, loaded.instances, zone)
+ val fontScale = context.resources.configuration.fontScale
+ val weekHeight = (LocalSize.current.height - gridChromeHeight(fontScale)) / weeks.size
+ val layouts = fitWeeks(weeks, lanesFitting(weekHeight))
+ val mode = LocalWidgetThemeMode.current
+ val colors = GridColors(
+ onSurface = remoteColor(WidgetColorRole.ON_SURFACE),
+ onSurfaceVariant = remoteColor(WidgetColorRole.ON_SURFACE_VARIANT),
+ primary = remoteColor(WidgetColorRole.PRIMARY),
+ onPrimary = remoteColor(WidgetColorRole.ON_PRIMARY),
+ cell = remoteColor(monthCellRole()),
+ )
- MonthHeader(label = monthLabel(ym, loaded.today.year))
- Spacer(GlanceModifier.height(2.dp))
- WeekdayHeader(weekStart = loaded.weekStart, colW = colW)
- weeks.forEach { week ->
- WeekRow(
- week = week,
- currentMonth = ym.month,
- today = loaded.today,
- dark = dark,
- soften = soften,
- colW = colW,
- modifier = GlanceModifier.defaultWeight(),
+ MonthHeader(label = monthLabel(ym, source.today.year), today = source.today)
+ Spacer(GlanceModifier.height(4.dp))
+ // No backing behind the grid: the weekday pills and day cells
+ // each carry their own fill, so a transparent widget stays
+ // readable without one blob over the wallpaper (#292).
+ Column(modifier = GlanceModifier.fillMaxWidth().defaultWeight()) {
+ WeekdayHeader(weekStart = source.weekStart)
+ AndroidRemoteViews(
+ remoteViews = monthGridViews(context, layouts, ym.month, source.today, source.soften, colors, mode),
+ modifier = GlanceModifier.fillMaxWidth().defaultWeight(),
)
}
}
@@ -238,89 +278,79 @@ private fun MonthWidgetBody(source: MonthWidgetSource, dark: Boolean, soften: Bo
}
@Composable
-private fun MonthHeader(label: String) {
+private fun MonthHeader(label: String, today: LocalDate) {
val context = LocalContext.current
- Row(
- modifier = GlanceModifier.fillMaxWidth(),
- verticalAlignment = Alignment.CenterVertically,
+ // Tap the month/year title to open the app on the month view (issue #18),
+ // rooted over the default home so backing out returns there.
+ WidgetHeader(
+ title = label,
+ titleAction = actionStartActivity(MainActivity.openViewIntent(context, CalendarView.Month)),
+ modifier = GlanceModifier.padding(bottom = 2.dp),
+ titleInset = 4.dp,
) {
- HeaderIcon(
- resId = R.drawable.ic_widget_chevron_left,
+ WidgetHeaderAction(
+ icon = ImageProvider(R.drawable.ic_widget_chevron_left),
contentDescription = context.getString(R.string.widget_prev_month),
- onClick = GlanceModifier.clickable(
- actionRunCallback(
- actionParametersOf(ShiftMonthAction.deltaKey to -1),
- ),
- ),
+ action = actionRunCallback(actionParametersOf(ShiftMonthAction.deltaKey to -1)),
+ style = WidgetActionStyle.TONAL,
)
- // Tap the month/year title to open the app on the month view (issue #18),
- // rooted over the default home so backing out returns there.
- Text(
- text = label,
- style = TextStyle(
- color = GlanceTheme.colors.primary,
- fontSize = 15.sp,
- fontWeight = FontWeight.Medium,
- textAlign = TextAlign.Center,
- ),
- modifier = GlanceModifier
- .defaultWeight()
- .clickable(
- actionStartActivity(
- MainActivity.openViewIntent(context, CalendarView.Month),
- ),
- ),
- )
- // The "today" button snaps the grid back to the current month in place.
- HeaderIcon(
- resId = R.drawable.ic_widget_today,
+ WidgetHeaderActionGap()
+ // The "today" pill carries today's number like the app icon, and snaps
+ // the grid back to the current month in place.
+ WidgetHeaderTextAction(
+ text = today.day.toString(),
contentDescription = context.getString(R.string.widget_today),
- onClick = GlanceModifier.clickable(actionRunCallback()),
+ action = actionRunCallback(),
+ style = WidgetActionStyle.TONAL,
)
- HeaderIcon(
- resId = R.drawable.ic_widget_chevron_right,
+ WidgetHeaderActionGap()
+ WidgetHeaderAction(
+ icon = ImageProvider(R.drawable.ic_widget_chevron_right),
contentDescription = context.getString(R.string.widget_next_month),
- onClick = GlanceModifier.clickable(
- actionRunCallback(
- actionParametersOf(ShiftMonthAction.deltaKey to 1),
- ),
- ),
+ action = actionRunCallback(actionParametersOf(ShiftMonthAction.deltaKey to 1)),
+ style = WidgetActionStyle.TONAL,
+ )
+ WidgetHeaderActionGap()
+ WidgetHeaderAction(
+ icon = ImageProvider(R.drawable.ic_widget_add),
+ contentDescription = context.getString(R.string.widget_new_event),
+ action = actionStartActivity(MainActivity.openCreateIntent(context, today)),
)
}
}
@Composable
-private fun HeaderIcon(resId: Int, contentDescription: String, onClick: GlanceModifier) {
- Box(
- modifier = GlanceModifier.size(40.dp).then(onClick),
- contentAlignment = Alignment.Center,
- ) {
- Image(
- provider = ImageProvider(resId),
- contentDescription = contentDescription,
- colorFilter = ColorFilter.tint(GlanceTheme.colors.onSurfaceVariant),
- modifier = GlanceModifier.size(20.dp),
- )
- }
-}
-
-@Composable
-private fun WeekdayHeader(weekStart: DayOfWeek, colW: Dp) {
- Row(modifier = GlanceModifier.fillMaxWidth()) {
+private fun WeekdayHeader(weekStart: DayOfWeek) {
+ Row(modifier = GlanceModifier.fillMaxWidth().padding(bottom = 1.dp)) {
weekdayNarrowNames(weekStart).forEach { name ->
- Text(
- text = name,
- style = TextStyle(
- color = GlanceTheme.colors.onSurfaceVariant,
- fontSize = 11.sp,
- textAlign = TextAlign.Center,
- ),
- modifier = GlanceModifier.width(colW),
- )
+ Box(modifier = GlanceModifier.defaultWeight().padding(horizontal = 1.dp)) {
+ Text(
+ text = name,
+ style = TextStyle(
+ color = GlanceTheme.colors.onPrimaryContainer,
+ fontSize = 11.sp,
+ textAlign = TextAlign.Center,
+ ),
+ modifier = GlanceModifier
+ .fillMaxWidth()
+ .cornerRadius(8.dp)
+ .background(GlanceTheme.colors.primaryContainer)
+ .padding(vertical = 1.dp),
+ )
+ }
}
}
}
+/** The fill of a day cell: one tone off a filled root, the widget surface over a wallpaper. */
+private fun monthCellRole(background: WidgetBackground): WidgetColorRole = when (background) {
+ WidgetBackground.SOLID -> WidgetColorRole.SURFACE_VARIANT
+ WidgetBackground.TINTED, WidgetBackground.TRANSPARENT -> WidgetColorRole.WIDGET_BACKGROUND
+}
+
+@Composable
+private fun monthCellRole(): WidgetColorRole = monthCellRole(LocalWidgetBackground.current)
+
/** Narrow weekday initials starting at [weekStart], in the device locale.
* Computed outside the composable so the locale read stays observable-safe. */
private fun weekdayNarrowNames(weekStart: DayOfWeek): List {
@@ -331,266 +361,6 @@ private fun weekdayNarrowNames(weekStart: DayOfWeek): List {
}
}
-@Composable
-private fun WeekRow(
- week: MonthWeek,
- currentMonth: Month,
- today: LocalDate,
- dark: Boolean,
- soften: Boolean,
- colW: Dp,
- modifier: GlanceModifier,
-) {
- val context = LocalContext.current
- val hidden = week.hiddenPerDay()
- Box(modifier = modifier.fillMaxWidth()) {
- // Day-open targets, one full-height strip per column, sitting *under* the
- // content: a tap anywhere in a day still opens it, as in the app, without
- // every cell in the column carrying a click target of its own. The content
- // above is not clickable, so touches fall through to this layer; only an
- // event bar opts out to open its own detail.
- Row(modifier = GlanceModifier.fillMaxSize()) {
- week.days.forEach { date ->
- Box(
- modifier = GlanceModifier
- .width(colW)
- .fillMaxHeight()
- .clickable(openDayAction(context, date)),
- ) {}
- }
- }
- Column(modifier = GlanceModifier.fillMaxWidth()) {
- // Day numbers. Fixed height so every week's number row lines up whether
- // or not it holds today's larger pill.
- Row(
- modifier = GlanceModifier.fillMaxWidth().height(DAY_NUMBER_HEIGHT),
- verticalAlignment = Alignment.CenterVertically,
- ) {
- week.days.forEach { date ->
- DayNumber(
- date = date,
- isToday = date == today,
- inMonth = date.month == currentMonth,
- colW = colW,
- )
- }
- }
- Spacer(GlanceModifier.height(2.dp))
- // One lane row per event row. A multi-day span is a single Box spanning
- // its columns (colW * n) so it's connected with no seam and rounded ends.
- // Lanes past the last one this week fills emit nothing at all.
- repeat(week.usedLanes()) { lane ->
- LaneRow(week = week, lane = lane, dark = dark, soften = soften, colW = colW)
- Spacer(GlanceModifier.height(1.dp))
- }
- if (hidden.any { it > 0 }) OverflowRow(hidden = hidden, colW = colW)
- }
- }
-}
-
-/**
- * How many lanes this week actually fills, counting from the top. The grid draws
- * that many rows instead of a fixed [MAX_LANES]; an empty lane costs three views
- * per day and most weeks leave the last two empty.
- */
-private fun MonthWeek.usedLanes(): Int {
- for (lane in MAX_LANES - 1 downTo 0) {
- val filled = spans.any { it.lane == lane } ||
- days.indices.any { col -> timedEventAt(this, lane, col, days[col]) != null }
- if (filled) return lane + 1
- }
- return 0
-}
-
-/** Events per day that no lane had room for — the "+N" counts, 0 where all fit. */
-private fun MonthWeek.hiddenPerDay(): List = days.mapIndexed { col, date ->
- val shownSpans = spans.count { col in it.startCol..it.endCol && it.lane < MAX_LANES }
- val freeSlots = (MAX_LANES - shownSpans).coerceAtLeast(0)
- val timedShown = minOf(freeSlots, timedByDay[date].orEmpty().size)
- ((countByDay[date] ?: 0) - shownSpans - timedShown).coerceAtLeast(0)
-}
-
-/**
- * Open [date]'s day view rooted in the month view (so back returns to the month
- * grid) — the same target the in-app month grid uses when a day cell is tapped.
- * Shared by every tappable part of a day column so, as in the app, a tap anywhere
- * on a day opens it; only an event bar on top opts out to open its own detail.
- */
-private fun openDayAction(context: Context, date: LocalDate) =
- actionStartActivity(MainActivity.openDateIntent(context, date, CalendarView.Month))
-
-/**
- * A day's number. Every day but today is a bare centred [Text] carrying the column
- * width itself — a wrapping Box costs three views, and there are 42 of these.
- * Today keeps its filled circle, which does need the Box.
- */
-@Composable
-private fun DayNumber(date: LocalDate, isToday: Boolean, inMonth: Boolean, colW: Dp) {
- val style = TextStyle(
- color = when {
- isToday -> GlanceTheme.colors.onPrimary
- inMonth -> GlanceTheme.colors.onSurface
- else -> GlanceTheme.colors.onSurfaceVariant
- },
- fontSize = 11.sp,
- fontWeight = if (isToday) FontWeight.Bold else FontWeight.Normal,
- textAlign = TextAlign.Center,
- )
- if (!isToday) {
- Text(text = date.day.toString(), style = style, modifier = GlanceModifier.width(colW))
- return
- }
- Box(modifier = GlanceModifier.width(colW), contentAlignment = Alignment.Center) {
- Box(
- modifier = GlanceModifier
- .size(DAY_NUMBER_HEIGHT)
- .cornerRadius(DAY_NUMBER_HEIGHT / 2)
- .background(GlanceTheme.colors.primary),
- contentAlignment = Alignment.Center,
- ) {
- Text(text = date.day.toString(), style = style)
- }
- }
-}
-
-/** A bar occupying [cols] columns, or — with a null event — that many blank ones. */
-private data class LaneCell(val event: EventInstance?, val cols: Int)
-
-/**
- * A lane split into bars and the gaps between them, with consecutive blank columns
- * merged into one gap: the week's day-open taps come from the strip underneath, so
- * a gap needs no per-column view and a mostly-empty lane collapses to a spacer.
- */
-private fun MonthWeek.laneCells(lane: Int): List {
- val cells = mutableListOf()
- var gap = 0
- var col = 0
- fun closeGap() {
- if (gap > 0) cells += LaneCell(null, gap)
- gap = 0
- }
- while (col < 7) {
- val span = spans.firstOrNull { it.lane == lane && col in it.startCol..it.endCol }
- val timed = if (span == null) timedEventAt(this, lane, col, days[col]) else null
- when {
- span != null -> {
- closeGap()
- cells += LaneCell(span.event, span.endCol - col + 1)
- col = span.endCol + 1
- }
- timed != null -> {
- closeGap()
- cells += LaneCell(timed, 1)
- col += 1
- }
- else -> {
- gap += 1
- col += 1
- }
- }
- }
- closeGap()
- return cells
-}
-
-@Composable
-private fun LaneRow(week: MonthWeek, lane: Int, dark: Boolean, soften: Boolean, colW: Dp) {
- Row(modifier = GlanceModifier.fillMaxWidth()) {
- week.laneCells(lane).forEach { cell ->
- if (cell.event == null) {
- Spacer(GlanceModifier.width(colW * cell.cols).height(LANE_HEIGHT))
- } else {
- SpanBar(
- event = cell.event,
- dark = dark,
- soften = soften,
- width = colW * cell.cols,
- )
- }
- }
- }
-}
-
-/** A single connected, rounded event bar [width] wide with its clipped title. */
-@Composable
-private fun SpanBar(event: EventInstance, dark: Boolean, soften: Boolean, width: Dp) {
- val context = LocalContext.current
- val fill = eventFill(event.color, dark, soften)
- Box(
- modifier = GlanceModifier
- .width(width)
- .height(LANE_HEIGHT)
- .padding(horizontal = 1.dp)
- // Tap an event bar to open its detail, rooted in the month view.
- .clickable(
- actionStartActivity(
- MainActivity.openEventIntent(
- context = context,
- eventId = event.eventId,
- beginMillis = event.start.toEpochMilliseconds(),
- endMillis = event.end.toEpochMilliseconds(),
- source = CalendarView.Month,
- ),
- ),
- ),
- ) {
- Box(
- modifier = GlanceModifier
- .fillMaxSize()
- .cornerRadius(4.dp)
- .background(fill),
- contentAlignment = Alignment.CenterStart,
- ) {
- Text(
- text = event.title.ifBlank { context.getString(R.string.event_untitled) },
- maxLines = 1,
- style = TextStyle(
- color = ColorProvider(eventInk(fill)),
- fontSize = 9.sp,
- textDecoration = glanceDeclinedDecoration(event.isDeclined),
- ),
- modifier = GlanceModifier.padding(horizontal = 3.dp),
- )
- }
- }
-}
-
-/** The "+N" row, drawn only for weeks that hide something, gaps merged as in a lane. */
-@Composable
-private fun OverflowRow(hidden: List, colW: Dp) {
- Row(modifier = GlanceModifier.fillMaxWidth()) {
- var gap = 0
- hidden.forEach { count ->
- if (count == 0) {
- gap += 1
- return@forEach
- }
- if (gap > 0) {
- Spacer(GlanceModifier.width(colW * gap).height(LANE_HEIGHT))
- gap = 0
- }
- Text(
- text = "+$count",
- maxLines = 1,
- style = TextStyle(color = GlanceTheme.colors.onSurfaceVariant, fontSize = 9.sp),
- modifier = GlanceModifier.width(colW).padding(start = 3.dp),
- )
- }
- if (gap > 0) Spacer(GlanceModifier.width(colW * gap).height(LANE_HEIGHT))
- }
-}
-
-/** The timed single-day event that fills lane [lane] on day [col], if any. */
-private fun timedEventAt(week: MonthWeek, lane: Int, col: Int, date: LocalDate): EventInstance? {
- val occupied = week.spans
- .filter { col in it.startCol..it.endCol && it.lane < MAX_LANES }
- .map { it.lane }
- .toSet()
- val freeSlots = (0 until MAX_LANES).filter { it !in occupied }
- val timed = week.timedByDay[date].orEmpty()
- return timed.getOrNull(freeSlots.indexOf(lane))
-}
-
@Composable
private fun PermissionMessage() {
val context = LocalContext.current
@@ -605,6 +375,7 @@ private fun PermissionMessage() {
fontSize = 14.sp,
textAlign = TextAlign.Center,
),
+ modifier = GlanceModifier.wallpaperBacking().padding(horizontal = 12.dp, vertical = 8.dp),
)
}
}
diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/widget/month/MonthWidgetReceiver.kt b/app/src/main/java/de/jeanlucmakiola/calendula/widget/month/MonthWidgetReceiver.kt
index 08e4a3e..f645a15 100644
--- a/app/src/main/java/de/jeanlucmakiola/calendula/widget/month/MonthWidgetReceiver.kt
+++ b/app/src/main/java/de/jeanlucmakiola/calendula/widget/month/MonthWidgetReceiver.kt
@@ -1,46 +1,9 @@
package de.jeanlucmakiola.calendula.widget.month
-import android.appwidget.AppWidgetManager
-import android.content.Context
import androidx.glance.appwidget.GlanceAppWidget
-import androidx.glance.appwidget.GlanceAppWidgetReceiver
-import de.jeanlucmakiola.calendula.widget.WidgetRolloverScheduler
+import de.jeanlucmakiola.calendula.widget.CalendulaWidgetReceiver
-/**
- * Host-facing receiver for the month widget. Declared in the manifest with the
- * `appwidget_info_month` provider metadata; delegates rendering to [MonthWidget].
- */
-class MonthWidgetReceiver : GlanceAppWidgetReceiver() {
+/** Host-facing receiver for the month widget; rendering is [MonthWidget]'s. */
+class MonthWidgetReceiver : CalendulaWidgetReceiver() {
override val glanceAppWidget: GlanceAppWidget = MonthWidget()
-
- /** First month widget placed — start rolling "today" over at midnight (#228). */
- override fun onEnabled(context: Context) {
- super.onEnabled(context)
- WidgetRolloverScheduler.sync(context)
- }
-
- /**
- * Last month widget removed. [WidgetRolloverScheduler.sync] cancels only if
- * no agenda widget is left either.
- */
- override fun onDisabled(context: Context) {
- super.onDisabled(context)
- WidgetRolloverScheduler.sync(context)
- }
-
- /**
- * The `updatePeriodMillis` backstop is the one wake-up the *system* still
- * owns, so it doubles as the alarm's self-heal: anything that drops a
- * pending alarm without a broadcast (force-stop, battery restriction, an OEM
- * freeze) is repaired here rather than on the next app open. Re-arming
- * closer to midnight also narrows the inexact delivery window.
- */
- override fun onUpdate(
- context: Context,
- appWidgetManager: AppWidgetManager,
- appWidgetIds: IntArray,
- ) {
- super.onUpdate(context, appWidgetManager, appWidgetIds)
- WidgetRolloverScheduler.sync(context)
- }
}
diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/widget/quickadd/QuickAddWidget.kt b/app/src/main/java/de/jeanlucmakiola/calendula/widget/quickadd/QuickAddWidget.kt
new file mode 100644
index 0000000..97a4642
--- /dev/null
+++ b/app/src/main/java/de/jeanlucmakiola/calendula/widget/quickadd/QuickAddWidget.kt
@@ -0,0 +1,92 @@
+package de.jeanlucmakiola.calendula.widget.quickadd
+
+import de.jeanlucmakiola.calendula.widget.WidgetKind
+import de.jeanlucmakiola.calendula.widget.previewSizeOf
+import de.jeanlucmakiola.calendula.widget.provideSamplePreview
+import android.content.Context
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.unit.dp
+import androidx.datastore.preferences.core.Preferences
+import androidx.glance.GlanceId
+import androidx.glance.GlanceModifier
+import androidx.glance.GlanceTheme
+import androidx.glance.ImageProvider
+import androidx.glance.LocalContext
+import androidx.glance.LocalSize
+import androidx.glance.action.clickable
+import androidx.glance.appwidget.GlanceAppWidget
+import androidx.glance.appwidget.SizeMode
+import androidx.glance.appwidget.action.actionStartActivity
+import androidx.glance.appwidget.provideContent
+import androidx.glance.currentState
+import androidx.glance.layout.Alignment
+import androidx.glance.layout.Box
+import androidx.glance.layout.fillMaxSize
+import androidx.glance.state.PreferencesGlanceStateDefinition
+import de.jeanlucmakiola.calendula.MainActivity
+import de.jeanlucmakiola.calendula.R
+import de.jeanlucmakiola.calendula.widget.CalendulaWidgetRoot
+import de.jeanlucmakiola.calendula.widget.WidgetAppearance
+import de.jeanlucmakiola.calendula.widget.CalendulaWidgetReceiver
+import de.jeanlucmakiola.calendula.widget.loadToday
+import de.jeanlucmakiola.calendula.widget.widgetAppearance
+import de.jeanlucmakiola.floret.glance.WidgetData
+import de.jeanlucmakiola.floret.glance.WidgetShape
+import de.jeanlucmakiola.floret.glance.WidgetShapedIconButton
+import de.jeanlucmakiola.floret.glance.currentRefreshStamp
+import de.jeanlucmakiola.floret.glance.refreshStamp
+import de.jeanlucmakiola.floret.prefs.ThemeMode
+import kotlinx.datetime.LocalDate
+
+/**
+ * 1×1 quick add (#435): one shaped "+" that opens the new-event editor on
+ * today, with the same defaults as the app's own new-event button.
+ */
+class QuickAddWidget : GlanceAppWidget() {
+
+ override val stateDefinition = PreferencesGlanceStateDefinition
+
+ // Exact: the shape fills whatever square the launcher gives it.
+ override val sizeMode = SizeMode.Exact
+
+ override val previewSizeMode = previewSizeOf(WidgetKind.QUICK_ADD)
+
+ override suspend fun providePreview(context: Context, widgetCategory: Int) =
+ provideSamplePreview(context, WidgetKind.QUICK_ADD)
+
+ override suspend fun provideGlance(context: Context, id: GlanceId) {
+ val widgetData = WidgetData(context.loadToday(), refreshStamp(context, id))
+ provideContent {
+ // The rollover moves the stamp, so the editor opens on the new day.
+ val loaded = widgetData.current(currentRefreshStamp()) { context.loadToday() }
+ QuickAddWidgetContent(loaded.content, currentState().widgetAppearance, loaded.appTheme)
+ }
+ }
+}
+
+class QuickAddWidgetReceiver : CalendulaWidgetReceiver() {
+ override val glanceAppWidget: GlanceAppWidget = QuickAddWidget()
+}
+
+@Composable
+internal fun QuickAddWidgetContent(today: LocalDate, appearance: WidgetAppearance, appTheme: ThemeMode) {
+ CalendulaWidgetRoot(appearance, appTheme) {
+ val context = LocalContext.current
+ val size = LocalSize.current
+ val action = actionStartActivity(MainActivity.openCreateIntent(context, today))
+ Box(
+ modifier = GlanceModifier.fillMaxSize().clickable(action),
+ contentAlignment = Alignment.Center,
+ ) {
+ WidgetShapedIconButton(
+ shape = WidgetShape.SUNNY,
+ icon = ImageProvider(R.drawable.ic_widget_add),
+ contentDescription = context.getString(R.string.widget_new_event),
+ action = action,
+ color = GlanceTheme.colors.primary,
+ iconTint = GlanceTheme.colors.onPrimary,
+ size = (minOf(size.width, size.height) - 12.dp).coerceIn(36.dp, 120.dp),
+ )
+ }
+ }
+}
diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/widget/split/MonthDayWidget.kt b/app/src/main/java/de/jeanlucmakiola/calendula/widget/split/MonthDayWidget.kt
new file mode 100644
index 0000000..b6ef071
--- /dev/null
+++ b/app/src/main/java/de/jeanlucmakiola/calendula/widget/split/MonthDayWidget.kt
@@ -0,0 +1,451 @@
+package de.jeanlucmakiola.calendula.widget.split
+
+import de.jeanlucmakiola.calendula.widget.WidgetKind
+import de.jeanlucmakiola.calendula.widget.previewSizeOf
+import de.jeanlucmakiola.calendula.widget.provideSamplePreview
+import android.content.Context
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.unit.Dp
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
+import androidx.datastore.preferences.core.Preferences
+import androidx.datastore.preferences.core.intPreferencesKey
+import androidx.datastore.preferences.core.stringPreferencesKey
+import androidx.glance.GlanceId
+import androidx.glance.GlanceModifier
+import androidx.glance.GlanceTheme
+import androidx.glance.ImageProvider
+import androidx.glance.LocalContext
+import androidx.glance.LocalSize
+import androidx.glance.action.ActionParameters
+import androidx.glance.action.actionParametersOf
+import androidx.glance.action.clickable
+import androidx.glance.appwidget.GlanceAppWidget
+import androidx.glance.appwidget.SizeMode
+import androidx.glance.appwidget.action.ActionCallback
+import androidx.glance.appwidget.action.actionRunCallback
+import androidx.glance.appwidget.action.actionStartActivity
+import androidx.glance.appwidget.cornerRadius
+import androidx.glance.appwidget.lazy.LazyColumn
+import androidx.glance.appwidget.lazy.items
+import androidx.glance.appwidget.provideContent
+import androidx.glance.appwidget.state.updateAppWidgetState
+import androidx.glance.background
+import androidx.glance.currentState
+import androidx.glance.layout.Alignment
+import androidx.glance.layout.Box
+import androidx.glance.layout.Column
+import androidx.glance.layout.Row
+import androidx.glance.layout.Spacer
+import androidx.glance.layout.fillMaxHeight
+import androidx.glance.layout.fillMaxSize
+import androidx.glance.layout.fillMaxWidth
+import androidx.glance.layout.height
+import androidx.glance.layout.padding
+import androidx.glance.layout.width
+import androidx.glance.state.PreferencesGlanceStateDefinition
+import androidx.glance.text.FontWeight
+import androidx.glance.text.Text
+import androidx.glance.text.TextAlign
+import androidx.glance.text.TextStyle
+import androidx.glance.unit.ColorProvider
+import de.jeanlucmakiola.calendula.MainActivity
+import de.jeanlucmakiola.calendula.R
+import de.jeanlucmakiola.calendula.data.prefs.PastEventDisplay
+import de.jeanlucmakiola.calendula.domain.hasEnded
+import de.jeanlucmakiola.calendula.ui.agenda.groupAgendaDays
+import de.jeanlucmakiola.calendula.ui.common.CalendarView
+import de.jeanlucmakiola.calendula.ui.common.formatCalendarTitle
+import de.jeanlucmakiola.calendula.widget.AgendaWidgetOptions
+import de.jeanlucmakiola.calendula.widget.CalendulaWidgetReceiver
+import de.jeanlucmakiola.calendula.widget.CalendulaWidgetRoot
+import de.jeanlucmakiola.calendula.widget.MonthWidgetSource
+import de.jeanlucmakiola.calendula.widget.Themed
+import de.jeanlucmakiola.calendula.widget.WidgetAppearance
+import de.jeanlucmakiola.calendula.widget.WidgetEventRow
+import de.jeanlucmakiola.calendula.widget.widgetLoadContext
+import de.jeanlucmakiola.calendula.widget.compactRowMetrics
+import de.jeanlucmakiola.calendula.widget.loadCachedMonthSource
+import de.jeanlucmakiola.calendula.widget.monthIndexOf
+import de.jeanlucmakiola.calendula.widget.monthWindowCenter
+import de.jeanlucmakiola.calendula.widget.relativeDayLabel
+import de.jeanlucmakiola.calendula.widget.systemZone
+import de.jeanlucmakiola.calendula.widget.today
+import de.jeanlucmakiola.calendula.widget.weekOf
+import de.jeanlucmakiola.calendula.widget.widgetAppearance
+import de.jeanlucmakiola.calendula.widget.widgetLocale
+import de.jeanlucmakiola.calendula.widget.widgetCardColor
+import de.jeanlucmakiola.calendula.widget.widgetEntryPoint
+import de.jeanlucmakiola.calendula.widget.widgetSettings
+import de.jeanlucmakiola.calendula.widget.widgetState
+import de.jeanlucmakiola.floret.locale.localizedDateFormatter
+import kotlinx.datetime.toJavaLocalDate
+import de.jeanlucmakiola.calendula.widget.widgetTimeLabel
+import de.jeanlucmakiola.calendula.data.prefs.is24Hour
+import de.jeanlucmakiola.floret.components.positionOf
+import de.jeanlucmakiola.floret.glance.LocalWidgetBackground
+import de.jeanlucmakiola.floret.glance.WidgetActionStyle
+import de.jeanlucmakiola.floret.glance.WidgetBackground
+import de.jeanlucmakiola.floret.glance.WidgetData
+import de.jeanlucmakiola.floret.glance.WidgetHeader
+import de.jeanlucmakiola.floret.glance.WidgetHeaderAction
+import de.jeanlucmakiola.floret.glance.WidgetHeaderActionGap
+import de.jeanlucmakiola.floret.glance.WidgetHeaderTextAction
+import de.jeanlucmakiola.floret.glance.WidgetMessage
+import de.jeanlucmakiola.floret.glance.currentRefreshStamp
+import de.jeanlucmakiola.floret.glance.refreshStamp
+import de.jeanlucmakiola.floret.glance.wallpaperBacking
+import de.jeanlucmakiola.floret.prefs.ThemeMode
+import kotlinx.coroutines.flow.first
+import kotlinx.datetime.DateTimeUnit
+import kotlinx.datetime.DayOfWeek
+import kotlinx.datetime.LocalDate
+import kotlinx.datetime.plus
+import kotlinx.datetime.toJavaDayOfWeek
+import kotlin.time.Clock
+import kotlin.time.Instant
+import java.time.format.TextStyle as JavaTextStyle
+
+/** Per-widget state: the shown month (`year * 12 + monthOrdinal`) and the selected day; both follow today when absent. */
+private val MONTH_INDEX_KEY = intPreferencesKey("split_month_index")
+private val SELECTED_KEY = stringPreferencesKey("split_selected")
+
+/** What the month + day widget draws: the month widget's window, plus what its list needs. */
+sealed interface MonthDayData {
+ data object NeedsPermission : MonthDayData
+ data class Ready(
+ val source: MonthWidgetSource.Ready,
+ val options: AgendaWidgetOptions,
+ val is24Hour: Boolean,
+ val now: Instant,
+ ) : MonthDayData
+}
+
+/** [loaded]'s month window with what the day list draws by. */
+internal suspend fun Context.loadMonthDay(loaded: Themed, options: AgendaWidgetOptions): Themed {
+ val ready = loaded.content as? MonthWidgetSource.Ready ?: return Themed(MonthDayData.NeedsPermission, loaded.appTheme)
+ val settings = widgetEntryPoint().settingsPrefs().widgetSettings.first()
+ return Themed(
+ MonthDayData.Ready(
+ source = ready,
+ options = options,
+ is24Hour = settings.timeFormat.is24Hour(android.text.format.DateFormat.is24HourFormat(this)),
+ now = Clock.System.now(),
+ ),
+ loaded.appTheme,
+ )
+}
+
+/**
+ * Month + day (#441): a compact month grid beside the selected day's events,
+ * like Samsung Calendar's. A day in the grid selects it, starting on today;
+ * the list and its events open the app. The plain month widget keeps
+ * tap-opens-the-day (#386).
+ */
+class MonthDayWidget : GlanceAppWidget() {
+
+ override val stateDefinition = PreferencesGlanceStateDefinition
+
+ override val sizeMode = SizeMode.Exact
+
+ override val previewSizeMode = previewSizeOf(WidgetKind.MONTH_DAY)
+
+ override suspend fun providePreview(context: Context, widgetCategory: Int) =
+ provideSamplePreview(context, WidgetKind.MONTH_DAY)
+
+ override suspend fun provideGlance(context: Context, id: GlanceId) {
+ val stamp = refreshStamp(context, id)
+ val shown = context.widgetState(id)[MONTH_INDEX_KEY] ?: monthIndexOf(today(systemZone()))
+ val center = monthWindowCenter(shown)
+ val load: suspend (Int, Long) -> Themed = { c, s ->
+ context.loadMonthDay(context.loadCachedMonthSource(id, c, s), context.widgetLoadContext(id).options)
+ }
+ val widgetData = WidgetData(load(center, stamp), center, stamp)
+ provideContent {
+ val index = currentState(MONTH_INDEX_KEY) ?: monthIndexOf(today(systemZone()))
+ val currentCenter = monthWindowCenter(index)
+ val currentStamp = currentRefreshStamp()
+ val loaded = widgetData.current(currentCenter, currentStamp) { load(currentCenter, currentStamp) }
+ val selected = currentState(SELECTED_KEY)?.let(LocalDate::parse)
+ MonthDayWidgetContent(loaded.content, index, selected, currentState().widgetAppearance, loaded.appTheme)
+ }
+ }
+}
+
+class MonthDayWidgetReceiver : CalendulaWidgetReceiver() {
+ override val glanceAppWidget: GlanceAppWidget = MonthDayWidget()
+}
+
+/** Selects the tapped day; the list redraws from what's loaded. */
+class SelectDayAction : ActionCallback {
+ override suspend fun onAction(context: Context, glanceId: GlanceId, parameters: ActionParameters) {
+ val date = parameters[dateKey] ?: return
+ updateAppWidgetState(context, glanceId) { prefs ->
+ if (date == today(systemZone()).toString()) prefs.remove(SELECTED_KEY) else prefs[SELECTED_KEY] = date
+ }
+ MonthDayWidget().update(context.applicationContext, glanceId)
+ }
+
+ companion object {
+ val dateKey = ActionParameters.Key("date")
+ }
+}
+
+/** Pages the grid by the `delta` parameter; landing on the current month follows the date again. */
+class ShiftMonthDayAction : ActionCallback {
+ override suspend fun onAction(context: Context, glanceId: GlanceId, parameters: ActionParameters) {
+ val delta = parameters[deltaKey] ?: 0
+ val current = monthIndexOf(today(systemZone()))
+ updateAppWidgetState(context, glanceId) { prefs ->
+ val next = (prefs[MONTH_INDEX_KEY] ?: current) + delta
+ if (next == current) prefs.remove(MONTH_INDEX_KEY) else prefs[MONTH_INDEX_KEY] = next
+ }
+ MonthDayWidget().update(context.applicationContext, glanceId)
+ }
+
+ companion object {
+ val deltaKey = ActionParameters.Key("delta")
+ }
+}
+
+/** Back to this month with today selected. */
+class ResetMonthDayAction : ActionCallback {
+ override suspend fun onAction(context: Context, glanceId: GlanceId, parameters: ActionParameters) {
+ updateAppWidgetState(context, glanceId) { prefs ->
+ prefs.remove(MONTH_INDEX_KEY)
+ prefs.remove(SELECTED_KEY)
+ }
+ MonthDayWidget().update(context.applicationContext, glanceId)
+ }
+}
+
+/** The grid's days: whole weeks from [weekStart] covering [index]'s month. */
+internal fun monthGridDays(index: Int, weekStart: DayOfWeek): List {
+ val first = LocalDate(index / 12, index % 12 + 1, 1)
+ val start = weekOf(first, weekStart).start
+ val last = first.plus(1, DateTimeUnit.MONTH).plus(-1, DateTimeUnit.DAY)
+ val weeks = (start.daysUntilInclusive(last) + 6) / 7
+ return List(weeks * 7) { start.plus(it, DateTimeUnit.DAY) }
+}
+
+private fun LocalDate.daysUntilInclusive(other: LocalDate): Int =
+ (other.toEpochDays() - toEpochDays()).toInt() + 1
+
+private val GridGap = 8.dp
+
+@Composable
+internal fun MonthDayWidgetContent(
+ data: MonthDayData,
+ index: Int,
+ selected: LocalDate?,
+ appearance: WidgetAppearance,
+ appTheme: ThemeMode,
+ preview: Boolean = false,
+) {
+ CalendulaWidgetRoot(appearance, appTheme) {
+ val context = LocalContext.current
+ when (data) {
+ MonthDayData.NeedsPermission -> WidgetMessage(context.getString(R.string.widget_needs_permission))
+ is MonthDayData.Ready -> {
+ val source = data.source
+ val day = selected ?: source.today
+ val zone = systemZone()
+ val days = monthGridDays(index, source.weekStart)
+ val busy = groupAgendaDays(days.first(), days.last(), source.instances, zone).map { it.date }.toSet()
+ Column(modifier = GlanceModifier.fillMaxSize().padding(horizontal = 8.dp, vertical = 6.dp)) {
+ Header(index, source.today)
+ Spacer(GlanceModifier.height(4.dp))
+ Row(modifier = GlanceModifier.fillMaxWidth().defaultWeight()) {
+ val gridWidth = (LocalSize.current.width - 16.dp - GridGap) * 0.52f
+ Box(modifier = GlanceModifier.width(gridWidth).fillMaxHeight()) {
+ Grid(days, index, source, day, busy, gridWidth)
+ }
+ Spacer(GlanceModifier.width(GridGap))
+ Box(modifier = GlanceModifier.defaultWeight().fillMaxHeight()) {
+ DayList(day, data, preview)
+ }
+ }
+ }
+ }
+ }
+ }
+}
+
+@Composable
+private fun Header(index: Int, today: LocalDate) {
+ val context = LocalContext.current
+ WidgetHeader(
+ title = formatCalendarTitle(
+ date = java.time.LocalDate.of(index / 12, index % 12 + 1, 1),
+ locale = widgetLocale(),
+ currentYear = today.year,
+ skeleton = "LLLL",
+ ),
+ titleAction = actionStartActivity(MainActivity.openViewIntent(context, CalendarView.Month)),
+ modifier = GlanceModifier.padding(bottom = 2.dp),
+ titleInset = 4.dp,
+ ) {
+ WidgetHeaderAction(
+ icon = ImageProvider(R.drawable.ic_widget_chevron_left),
+ contentDescription = context.getString(R.string.widget_prev_month),
+ action = actionRunCallback(actionParametersOf(ShiftMonthDayAction.deltaKey to -1)),
+ style = WidgetActionStyle.TONAL,
+ )
+ WidgetHeaderActionGap()
+ WidgetHeaderTextAction(
+ text = today.day.toString(),
+ contentDescription = context.getString(R.string.widget_today),
+ action = actionRunCallback(),
+ style = WidgetActionStyle.TONAL,
+ )
+ WidgetHeaderActionGap()
+ WidgetHeaderAction(
+ icon = ImageProvider(R.drawable.ic_widget_chevron_right),
+ contentDescription = context.getString(R.string.widget_next_month),
+ action = actionRunCallback(actionParametersOf(ShiftMonthDayAction.deltaKey to 1)),
+ style = WidgetActionStyle.TONAL,
+ )
+ WidgetHeaderActionGap()
+ WidgetHeaderAction(
+ icon = ImageProvider(R.drawable.ic_widget_add),
+ contentDescription = context.getString(R.string.widget_new_event),
+ action = actionStartActivity(MainActivity.openCreateIntent(context, today)),
+ )
+ }
+}
+
+/**
+ * The month as numbers: selected day filled, today in bold primary, days
+ * with events dotted, days around the month faded. Three views a day keeps
+ * a six-week month well inside Glance's view ids (#372).
+ */
+@Composable
+private fun Grid(
+ days: List,
+ index: Int,
+ source: MonthWidgetSource.Ready,
+ selected: LocalDate,
+ busy: Set,
+ width: Dp,
+) {
+ val locale = widgetLocale()
+ val month = index % 12 + 1
+ val backing = if (LocalWidgetBackground.current == WidgetBackground.TRANSPARENT) {
+ GlanceModifier.cornerRadius(14.dp).background(widgetCardColor())
+ } else {
+ GlanceModifier
+ }
+ Column(modifier = backing.fillMaxSize().padding(2.dp)) {
+ Row(modifier = GlanceModifier.fillMaxWidth()) {
+ for (i in 0 until 7) {
+ Text(
+ text = java.time.DayOfWeek.of((source.weekStart.ordinal + i) % 7 + 1).getDisplayName(JavaTextStyle.NARROW, locale),
+ style = TextStyle(color = GlanceTheme.colors.onSurfaceVariant, fontSize = 10.sp, textAlign = TextAlign.Center),
+ modifier = GlanceModifier.width((width - 4.dp) / 7),
+ )
+ }
+ }
+ days.chunked(7).forEach { week ->
+ Row(modifier = GlanceModifier.fillMaxWidth().defaultWeight(), verticalAlignment = Alignment.CenterVertically) {
+ week.forEach { date ->
+ DayCell(date, date.month.ordinal + 1 == month, date == source.today, date == selected, date in busy, (width - 4.dp) / 7)
+ }
+ }
+ }
+ }
+}
+
+@Composable
+private fun DayCell(date: LocalDate, inMonth: Boolean, isToday: Boolean, isSelected: Boolean, hasEvents: Boolean, width: Dp) {
+ val ink = when {
+ isSelected -> GlanceTheme.colors.onPrimary
+ isToday -> GlanceTheme.colors.primary
+ inMonth -> GlanceTheme.colors.onSurface
+ else -> GlanceTheme.colors.outline
+ }
+ // Always set: the host reapplies onto reused views, so a dropped background would linger.
+ val fill = if (isSelected) GlanceTheme.colors.primary else ColorProvider(Color.Transparent)
+ Column(
+ modifier = GlanceModifier.cornerRadius(10.dp).background(fill)
+ .width(width)
+ .fillMaxHeight()
+ .clickable(actionRunCallback(actionParametersOf(SelectDayAction.dateKey to date.toString()))),
+ horizontalAlignment = Alignment.CenterHorizontally,
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ Text(
+ text = date.day.toString(),
+ maxLines = 1,
+ style = TextStyle(
+ color = ink,
+ fontSize = 11.sp,
+ fontWeight = if (isToday || isSelected) FontWeight.Bold else FontWeight.Normal,
+ textAlign = TextAlign.Center,
+ ),
+ )
+ Text(
+ text = if (hasEvents) "•" else " ",
+ maxLines = 1,
+ style = TextStyle(color = if (isSelected) ink else GlanceTheme.colors.primary, fontSize = 7.sp, textAlign = TextAlign.Center),
+ )
+ }
+}
+
+@Composable
+private fun DayList(day: LocalDate, data: MonthDayData.Ready, preview: Boolean) {
+ val context = LocalContext.current
+ val source = data.source
+ val zone = systemZone()
+ val events = groupAgendaDays(day, day, source.instances, zone).firstOrNull()?.events.orEmpty()
+ .let { all -> if (data.options.pastDisplay == PastEventDisplay.HIDE) all.filterNot { it.hasEnded(data.now) } else all }
+ val metrics = compactRowMetrics(data.options.textSize, context.resources.configuration.fontScale)
+ val openDay = actionStartActivity(MainActivity.openDateIntent(context, day, CalendarView.Month))
+ Column(modifier = GlanceModifier.fillMaxSize()) {
+ val date = localizedDateFormatter(widgetLocale(), "EEEdMMM").format(day.toJavaLocalDate())
+ val label = when (day) {
+ source.today, source.today.plus(1, DateTimeUnit.DAY) -> "${relativeDayLabel(context, day, source.today)} · $date"
+ else -> date
+ }
+ Text(
+ text = label,
+ maxLines = 1,
+ style = TextStyle(color = GlanceTheme.colors.onSurface, fontSize = metrics.time, fontWeight = FontWeight.Medium),
+ modifier = GlanceModifier
+ .padding(bottom = 4.dp)
+ .wallpaperBacking(radius = 8.dp)
+ .padding(horizontal = 6.dp, vertical = 2.dp)
+ .clickable(openDay),
+ )
+ if (events.isEmpty()) {
+ Box(
+ modifier = GlanceModifier.fillMaxSize().cornerRadius(14.dp).background(widgetCardColor()).clickable(openDay),
+ contentAlignment = Alignment.Center,
+ ) {
+ Text(
+ text = context.getString(R.string.widget_day_empty),
+ style = TextStyle(color = GlanceTheme.colors.onSurfaceVariant, fontSize = metrics.time, textAlign = TextAlign.Center),
+ modifier = GlanceModifier.padding(8.dp),
+ )
+ }
+ } else {
+ val row: @Composable (Int) -> Unit = { i ->
+ val event = events[i]
+ WidgetEventRow(
+ event = event,
+ secondary = widgetTimeLabel(context, event, day, data.is24Hour),
+ soften = source.soften,
+ metrics = metrics,
+ position = positionOf(i, events.size),
+ source = CalendarView.Month,
+ dimmed = data.options.pastDisplay == PastEventDisplay.DIM && event.hasEnded(data.now),
+ )
+ }
+ if (preview) {
+ Column(modifier = GlanceModifier.fillMaxSize()) { repeat(minOf(events.size, 8)) { row(it) } }
+ } else {
+ LazyColumn(modifier = GlanceModifier.fillMaxSize()) { items(events.size) { row(it) } }
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/widget/today/TodayWidget.kt b/app/src/main/java/de/jeanlucmakiola/calendula/widget/today/TodayWidget.kt
new file mode 100644
index 0000000..bd6e757
--- /dev/null
+++ b/app/src/main/java/de/jeanlucmakiola/calendula/widget/today/TodayWidget.kt
@@ -0,0 +1,252 @@
+package de.jeanlucmakiola.calendula.widget.today
+
+import de.jeanlucmakiola.calendula.widget.WidgetKind
+import de.jeanlucmakiola.calendula.widget.previewSizeOf
+import de.jeanlucmakiola.calendula.widget.provideSamplePreview
+import android.content.Context
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
+import androidx.datastore.preferences.core.Preferences
+import androidx.glance.GlanceId
+import androidx.glance.GlanceModifier
+import androidx.glance.GlanceTheme
+import androidx.glance.LocalContext
+import androidx.glance.LocalSize
+import androidx.glance.action.clickable
+import androidx.glance.appwidget.GlanceAppWidget
+import androidx.glance.appwidget.SizeMode
+import androidx.glance.appwidget.action.actionStartActivity
+import androidx.glance.appwidget.cornerRadius
+import androidx.glance.appwidget.lazy.LazyColumn
+import androidx.glance.appwidget.lazy.items
+import androidx.glance.appwidget.provideContent
+import androidx.glance.background
+import androidx.glance.currentState
+import androidx.glance.layout.Alignment
+import androidx.glance.layout.Box
+import androidx.glance.layout.Column
+import androidx.glance.layout.Row
+import androidx.glance.layout.Spacer
+import androidx.glance.layout.fillMaxHeight
+import androidx.glance.layout.fillMaxSize
+import androidx.glance.layout.fillMaxWidth
+import androidx.glance.layout.height
+import androidx.glance.layout.padding
+import androidx.glance.layout.width
+import androidx.glance.semantics.contentDescription
+import androidx.glance.semantics.semantics
+import androidx.glance.state.PreferencesGlanceStateDefinition
+import androidx.glance.text.FontWeight
+import androidx.glance.text.Text
+import androidx.glance.text.TextAlign
+import androidx.glance.text.TextStyle
+import de.jeanlucmakiola.calendula.MainActivity
+import de.jeanlucmakiola.calendula.R
+import de.jeanlucmakiola.calendula.data.prefs.PastEventDisplay
+import de.jeanlucmakiola.calendula.domain.EventInstance
+import de.jeanlucmakiola.calendula.domain.hasEnded
+import de.jeanlucmakiola.calendula.ui.common.CalendarView
+import de.jeanlucmakiola.calendula.widget.CalendulaWidgetReceiver
+import de.jeanlucmakiola.calendula.widget.CalendulaWidgetRoot
+import de.jeanlucmakiola.calendula.widget.DaysWidgetData
+import de.jeanlucmakiola.calendula.widget.EventRowMetrics
+import de.jeanlucmakiola.calendula.widget.WidgetAppearance
+import de.jeanlucmakiola.calendula.widget.WidgetEventRow
+import de.jeanlucmakiola.calendula.widget.WidgetLoadContext
+import de.jeanlucmakiola.calendula.widget.compactRowMetrics
+import de.jeanlucmakiola.calendula.widget.loadDays
+import de.jeanlucmakiola.calendula.widget.relativeDayLabel
+import de.jeanlucmakiola.calendula.widget.widgetAppearance
+import de.jeanlucmakiola.calendula.widget.widgetLocale
+import de.jeanlucmakiola.calendula.widget.widgetLoadContext
+import de.jeanlucmakiola.calendula.widget.widgetTimeLabel
+import de.jeanlucmakiola.floret.components.positionOf
+import de.jeanlucmakiola.floret.glance.LocalWidgetBackground
+import de.jeanlucmakiola.floret.glance.WidgetBackground
+import de.jeanlucmakiola.floret.glance.WidgetData
+import de.jeanlucmakiola.floret.glance.WidgetMessage
+import de.jeanlucmakiola.floret.glance.currentRefreshStamp
+import de.jeanlucmakiola.floret.glance.refreshStamp
+import de.jeanlucmakiola.floret.glance.wallpaperBacking
+import de.jeanlucmakiola.floret.locale.localizedDateFormatter
+import de.jeanlucmakiola.floret.prefs.ThemeMode
+import kotlinx.datetime.DateTimeUnit
+import kotlinx.datetime.LocalDate
+import kotlinx.datetime.plus
+import kotlinx.datetime.toJavaDayOfWeek
+import kotlinx.datetime.toJavaLocalDate
+import java.time.format.TextStyle as JavaTextStyle
+
+/**
+ * Today list (#438): only today's events beside today's date pill. Once
+ * they're over, or with none at all, it says so and shows tomorrow's first
+ * one or two events in a quieter style.
+ */
+class TodayWidget : GlanceAppWidget() {
+
+ override val stateDefinition = PreferencesGlanceStateDefinition
+
+ // Exact: whether one or two of tomorrow's events fit follows the height.
+ override val sizeMode = SizeMode.Exact
+
+ override val previewSizeMode = previewSizeOf(WidgetKind.TODAY)
+
+ override suspend fun providePreview(context: Context, widgetCategory: Int) =
+ provideSamplePreview(context, WidgetKind.TODAY)
+
+ override suspend fun provideGlance(context: Context, id: GlanceId) {
+ val widgetData = WidgetData(context.loadTodayList(context.widgetLoadContext(id)), refreshStamp(context, id))
+ provideContent {
+ val loaded = widgetData.current(currentRefreshStamp()) { context.loadTodayList(context.widgetLoadContext(id)) }
+ TodayWidgetContent(loaded.content, currentState().widgetAppearance, loaded.appTheme)
+ }
+ }
+}
+
+class TodayWidgetReceiver : CalendulaWidgetReceiver() {
+ override val glanceAppWidget: GlanceAppWidget = TodayWidget()
+}
+
+internal suspend fun Context.loadTodayList(load: WidgetLoadContext) = loadDays(load) { today, _ ->
+ today..today.plus(1, DateTimeUnit.DAY)
+}
+
+private val PillWidth = 44.dp
+private val RowGap = 2.dp
+
+/** What the list side shows: today's events, or the look ahead once none are left. */
+internal sealed interface TodayRows {
+ data class Today(val events: List) : TodayRows
+ data class NothingLeft(val tomorrow: List) : TodayRows
+}
+
+internal fun todayRows(data: DaysWidgetData.Ready): TodayRows {
+ val today = data.eventsOn(data.today)
+ if (today.none { !it.hasEnded(data.now) }) {
+ return TodayRows.NothingLeft(data.eventsOn(data.today.plus(1, DateTimeUnit.DAY)))
+ }
+ val shown = if (data.options.pastDisplay == PastEventDisplay.HIDE) today.filterNot { it.hasEnded(data.now) } else today
+ return TodayRows.Today(shown)
+}
+
+/** The whole widget, shared with its config preview, which draws its list as a plain column. */
+@Composable
+internal fun TodayWidgetContent(
+ data: DaysWidgetData,
+ appearance: WidgetAppearance,
+ appTheme: ThemeMode,
+ preview: Boolean = false,
+) {
+ CalendulaWidgetRoot(appearance, appTheme) {
+ val context = LocalContext.current
+ when (data) {
+ DaysWidgetData.NeedsPermission -> WidgetMessage(context.getString(R.string.widget_needs_permission))
+ is DaysWidgetData.Ready -> Row(modifier = GlanceModifier.fillMaxSize().padding(6.dp)) {
+ TodayPill(data.today)
+ Spacer(GlanceModifier.width(6.dp))
+ val metrics = compactRowMetrics(data.options.textSize, context.resources.configuration.fontScale)
+ Box(modifier = GlanceModifier.defaultWeight().fillMaxHeight()) {
+ when (val rows = todayRows(data)) {
+ is TodayRows.Today -> TodayList(rows.events, data, metrics, preview)
+ is TodayRows.NothingLeft -> NothingLeft(rows.tomorrow, data, metrics)
+ }
+ }
+ }
+ }
+ }
+}
+
+@Composable
+private fun TodayList(events: List, data: DaysWidgetData.Ready, metrics: EventRowMetrics, preview: Boolean) {
+ val context = LocalContext.current
+ val row: @Composable (Int) -> Unit = { index ->
+ val event = events[index]
+ WidgetEventRow(
+ event = event,
+ secondary = widgetTimeLabel(context, event, data.today, data.is24Hour),
+ soften = data.soften,
+ metrics = metrics,
+ position = positionOf(index, events.size),
+ source = CalendarView.Day,
+ dimmed = data.options.pastDisplay == PastEventDisplay.DIM && event.hasEnded(data.now),
+ )
+ }
+ if (preview) {
+ val fits = ((LocalSize.current.height - 12.dp + RowGap) / (metrics.rowHeight + RowGap)).toInt().coerceAtLeast(1)
+ Column(modifier = GlanceModifier.fillMaxSize()) { repeat(minOf(events.size, fits)) { row(it) } }
+ } else {
+ LazyColumn(modifier = GlanceModifier.fillMaxSize()) { items(events.size) { row(it) } }
+ }
+}
+
+/** "Nothing left today", then as many of tomorrow's first two events as the height holds. */
+@Composable
+private fun NothingLeft(tomorrow: List, data: DaysWidgetData.Ready, metrics: EventRowMetrics) {
+ val context = LocalContext.current
+ val fontScale = context.resources.configuration.fontScale
+ val lineHeight = 20.dp * fontScale
+ val room = LocalSize.current.height - 12.dp - lineHeight
+ val fits = ((room + RowGap) / (metrics.rowHeight + RowGap)).toInt().coerceIn(0, 2)
+ val ahead = tomorrow.take(fits)
+ val backing = if (LocalWidgetBackground.current == WidgetBackground.TRANSPARENT) {
+ GlanceModifier.wallpaperBacking(radius = 8.dp).padding(horizontal = 8.dp)
+ } else {
+ GlanceModifier.padding(horizontal = 4.dp)
+ }
+ Column(modifier = GlanceModifier.fillMaxSize(), verticalAlignment = Alignment.CenterVertically) {
+ Text(
+ text = context.getString(R.string.widget_nothing_left_today),
+ maxLines = 1,
+ style = TextStyle(color = GlanceTheme.colors.onSurfaceVariant, fontSize = metrics.title, fontWeight = FontWeight.Medium),
+ modifier = backing.height(lineHeight),
+ )
+ val tomorrowDate = data.today.plus(1, DateTimeUnit.DAY)
+ val tomorrowLabel = relativeDayLabel(context, tomorrowDate, data.today)
+ ahead.forEachIndexed { index, event ->
+ WidgetEventRow(
+ event = event,
+ secondary = "$tomorrowLabel · ${widgetTimeLabel(context, event, tomorrowDate, data.is24Hour)}",
+ soften = data.soften,
+ metrics = metrics,
+ position = positionOf(index, ahead.size),
+ source = CalendarView.Day,
+ quiet = true,
+ )
+ }
+ }
+}
+
+/** Today's weekday and number, filled like the agenda's today pill; opens today. */
+@Composable
+private fun TodayPill(today: LocalDate) {
+ val context = LocalContext.current
+ val locale = widgetLocale()
+ Column(
+ modifier = GlanceModifier
+ .width(PillWidth)
+ .fillMaxHeight()
+ .cornerRadius(PillWidth / 2)
+ .background(GlanceTheme.colors.primary)
+ .clickable(actionStartActivity(MainActivity.openDateIntent(context, today, CalendarView.Day)))
+ .semantics { contentDescription = localizedDateFormatter(locale, "EEEEdMMMM").format(today.toJavaLocalDate()) },
+ horizontalAlignment = Alignment.CenterHorizontally,
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ Text(
+ text = today.dayOfWeek.toJavaDayOfWeek().getDisplayName(JavaTextStyle.SHORT, locale),
+ maxLines = 1,
+ style = TextStyle(color = GlanceTheme.colors.onPrimary, fontSize = 11.sp, textAlign = TextAlign.Center),
+ )
+ Text(
+ text = today.day.toString(),
+ maxLines = 1,
+ style = TextStyle(
+ color = GlanceTheme.colors.onPrimary,
+ fontSize = 18.sp,
+ fontWeight = FontWeight.Medium,
+ textAlign = TextAlign.Center,
+ ),
+ )
+ }
+}
diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/widget/upcoming/UpcomingWidget.kt b/app/src/main/java/de/jeanlucmakiola/calendula/widget/upcoming/UpcomingWidget.kt
new file mode 100644
index 0000000..1f4ddd7
--- /dev/null
+++ b/app/src/main/java/de/jeanlucmakiola/calendula/widget/upcoming/UpcomingWidget.kt
@@ -0,0 +1,188 @@
+package de.jeanlucmakiola.calendula.widget.upcoming
+
+import de.jeanlucmakiola.calendula.widget.WidgetKind
+import de.jeanlucmakiola.calendula.widget.previewSizeOf
+import de.jeanlucmakiola.calendula.widget.provideSamplePreview
+import android.content.Context
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.unit.Dp
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
+import androidx.datastore.preferences.core.Preferences
+import androidx.glance.GlanceId
+import androidx.glance.GlanceModifier
+import androidx.glance.GlanceTheme
+import androidx.glance.LocalContext
+import androidx.glance.LocalSize
+import androidx.glance.action.clickable
+import androidx.glance.appwidget.GlanceAppWidget
+import androidx.glance.appwidget.SizeMode
+import androidx.glance.appwidget.action.actionStartActivity
+import androidx.glance.appwidget.provideContent
+import androidx.glance.currentState
+import androidx.glance.layout.Alignment
+import androidx.glance.layout.Box
+import androidx.glance.layout.Column
+import androidx.glance.layout.Row
+import androidx.glance.layout.Spacer
+import androidx.glance.layout.fillMaxSize
+import androidx.glance.layout.fillMaxWidth
+import androidx.glance.layout.height
+import androidx.glance.layout.padding
+import androidx.glance.layout.width
+import androidx.glance.state.PreferencesGlanceStateDefinition
+import androidx.glance.text.FontWeight
+import androidx.glance.text.Text
+import androidx.glance.text.TextStyle
+import de.jeanlucmakiola.calendula.MainActivity
+import de.jeanlucmakiola.calendula.R
+import de.jeanlucmakiola.calendula.domain.EventInstance
+import de.jeanlucmakiola.calendula.domain.hasEnded
+import de.jeanlucmakiola.calendula.ui.agenda.dayCount
+import de.jeanlucmakiola.calendula.ui.common.CalendarView
+import de.jeanlucmakiola.calendula.widget.CalendulaWidgetReceiver
+import de.jeanlucmakiola.calendula.widget.CalendulaWidgetRoot
+import de.jeanlucmakiola.calendula.widget.DaysWidgetData
+import de.jeanlucmakiola.calendula.widget.WidgetAppearance
+import de.jeanlucmakiola.calendula.widget.WidgetEventRow
+import de.jeanlucmakiola.calendula.widget.WidgetLoadContext
+import de.jeanlucmakiola.calendula.widget.compactRowMetrics
+import de.jeanlucmakiola.calendula.widget.loadDays
+import de.jeanlucmakiola.calendula.widget.relativeDayLabel
+import de.jeanlucmakiola.calendula.widget.widgetAppearance
+import de.jeanlucmakiola.calendula.widget.widgetLocale
+import de.jeanlucmakiola.calendula.widget.widgetLoadContext
+import de.jeanlucmakiola.calendula.widget.widgetTimeLabel
+import de.jeanlucmakiola.floret.components.positionOf
+import de.jeanlucmakiola.floret.glance.LocalWidgetBackground
+import de.jeanlucmakiola.floret.glance.WidgetBackground
+import de.jeanlucmakiola.floret.glance.WidgetData
+import de.jeanlucmakiola.floret.glance.WidgetMessage
+import de.jeanlucmakiola.floret.glance.currentRefreshStamp
+import de.jeanlucmakiola.floret.glance.refreshStamp
+import de.jeanlucmakiola.floret.glance.wallpaperBacking
+import de.jeanlucmakiola.floret.prefs.ThemeMode
+import kotlinx.datetime.DateTimeUnit
+import kotlinx.datetime.LocalDate
+import kotlinx.datetime.plus
+import kotlinx.datetime.toJavaDayOfWeek
+import java.time.format.TextStyle as JavaTextStyle
+
+/**
+ * 2×2 upcoming (#437): today's date on top, then the next events across the
+ * widget's range in compact rows, as many as its height holds. The date opens
+ * the agenda, an event opens itself.
+ */
+class UpcomingWidget : GlanceAppWidget() {
+
+ override val stateDefinition = PreferencesGlanceStateDefinition
+
+ // Exact: how many rows show follows the height.
+ override val sizeMode = SizeMode.Exact
+
+ override val previewSizeMode = previewSizeOf(WidgetKind.UPCOMING)
+
+ override suspend fun providePreview(context: Context, widgetCategory: Int) =
+ provideSamplePreview(context, WidgetKind.UPCOMING)
+
+ override suspend fun provideGlance(context: Context, id: GlanceId) {
+ val widgetData = WidgetData(context.loadUpcoming(context.widgetLoadContext(id)), refreshStamp(context, id))
+ provideContent {
+ val loaded = widgetData.current(currentRefreshStamp()) { context.loadUpcoming(context.widgetLoadContext(id)) }
+ UpcomingWidgetContent(loaded.content, currentState().widgetAppearance, loaded.appTheme)
+ }
+ }
+}
+
+class UpcomingWidgetReceiver : CalendulaWidgetReceiver() {
+ override val glanceAppWidget: GlanceAppWidget = UpcomingWidget()
+}
+
+internal suspend fun Context.loadUpcoming(load: WidgetLoadContext) = loadDays(load) { today, weekStart ->
+ today..today.plus(load.options.range.dayCount(today, weekStart) - 1, DateTimeUnit.DAY)
+}
+
+/** Most rows a tall widget lists; past this it is an agenda, which is its own widget. */
+private const val MAX_ROWS = 8
+
+private val RowGap = 2.dp
+
+/** Every event still to come or running, each once, on the first day it shows. */
+internal fun upcomingEvents(data: DaysWidgetData.Ready): List> =
+ data.days
+ .flatMap { day -> day.events.map { day.date to it } }
+ .filterNot { (_, event) -> event.hasEnded(data.now) }
+ .distinctBy { (_, event) -> event.instanceId to event.start }
+
+@Composable
+internal fun UpcomingWidgetContent(data: DaysWidgetData, appearance: WidgetAppearance, appTheme: ThemeMode) {
+ CalendulaWidgetRoot(appearance, appTheme) {
+ val context = LocalContext.current
+ when (data) {
+ DaysWidgetData.NeedsPermission -> WidgetMessage(context.getString(R.string.widget_needs_permission))
+ is DaysWidgetData.Ready -> {
+ val fontScale = context.resources.configuration.fontScale
+ val metrics = compactRowMetrics(data.options.textSize, fontScale)
+ val headerHeight = 44.dp * fontScale
+ val room = LocalSize.current.height - 16.dp - headerHeight
+ val fits = ((room + RowGap) / (metrics.rowHeight + RowGap)).toInt().coerceIn(1, MAX_ROWS)
+ val events = upcomingEvents(data).take(fits)
+ Column(modifier = GlanceModifier.fillMaxSize().padding(8.dp)) {
+ DateHeader(data.today, headerHeight)
+ if (events.isEmpty()) {
+ WidgetMessage(context.getString(R.string.widget_upcoming_empty))
+ } else {
+ events.forEachIndexed { index, (day, event) ->
+ val time = widgetTimeLabel(context, event, day, data.is24Hour)
+ WidgetEventRow(
+ event = event,
+ secondary = if (day == data.today) time else "${relativeDayLabel(context, day, data.today)} · $time",
+ soften = data.soften,
+ metrics = metrics,
+ position = positionOf(index, events.size),
+ source = CalendarView.Agenda,
+ )
+ }
+ }
+ }
+ }
+ }
+ }
+}
+
+/** Today's number beside its weekday and month; opens the agenda. */
+@Composable
+private fun DateHeader(today: LocalDate, height: Dp) {
+ val context = LocalContext.current
+ val locale = widgetLocale()
+ val backing = if (LocalWidgetBackground.current == WidgetBackground.TRANSPARENT) {
+ GlanceModifier.wallpaperBacking(radius = 12.dp).padding(horizontal = 8.dp)
+ } else {
+ GlanceModifier.padding(horizontal = 4.dp)
+ }
+ Box(modifier = GlanceModifier.fillMaxWidth().height(height), contentAlignment = Alignment.CenterStart) {
+ Row(
+ modifier = backing.clickable(actionStartActivity(MainActivity.openViewIntent(context, CalendarView.Agenda))),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ Text(
+ text = today.day.toString(),
+ maxLines = 1,
+ style = TextStyle(color = GlanceTheme.colors.primary, fontSize = 28.sp, fontWeight = FontWeight.Bold),
+ )
+ Spacer(GlanceModifier.width(8.dp))
+ Column {
+ Text(
+ text = today.dayOfWeek.toJavaDayOfWeek().getDisplayName(JavaTextStyle.FULL, locale),
+ maxLines = 1,
+ style = TextStyle(color = GlanceTheme.colors.onSurface, fontSize = 13.sp, fontWeight = FontWeight.Medium),
+ )
+ Text(
+ text = java.time.Month.of(today.month.ordinal + 1).getDisplayName(JavaTextStyle.FULL_STANDALONE, locale),
+ maxLines = 1,
+ style = TextStyle(color = GlanceTheme.colors.onSurfaceVariant, fontSize = 12.sp),
+ )
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/widget/week/WeekStripWidget.kt b/app/src/main/java/de/jeanlucmakiola/calendula/widget/week/WeekStripWidget.kt
new file mode 100644
index 0000000..eac2150
--- /dev/null
+++ b/app/src/main/java/de/jeanlucmakiola/calendula/widget/week/WeekStripWidget.kt
@@ -0,0 +1,196 @@
+package de.jeanlucmakiola.calendula.widget.week
+
+import de.jeanlucmakiola.calendula.widget.WidgetKind
+import de.jeanlucmakiola.calendula.widget.previewSizeOf
+import de.jeanlucmakiola.calendula.widget.provideSamplePreview
+import android.content.Context
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
+import androidx.datastore.preferences.core.Preferences
+import androidx.glance.ColorFilter
+import androidx.glance.GlanceId
+import androidx.glance.GlanceModifier
+import androidx.glance.GlanceTheme
+import androidx.glance.Image
+import androidx.glance.ImageProvider
+import androidx.glance.LocalContext
+import androidx.glance.action.clickable
+import androidx.glance.appwidget.GlanceAppWidget
+import androidx.glance.appwidget.SizeMode
+import androidx.glance.appwidget.action.actionStartActivity
+import androidx.glance.appwidget.cornerRadius
+import androidx.glance.appwidget.provideContent
+import androidx.glance.background
+import androidx.glance.currentState
+import androidx.glance.layout.Alignment
+import androidx.glance.layout.Box
+import androidx.glance.layout.Column
+import androidx.glance.layout.Row
+import androidx.glance.layout.Spacer
+import androidx.glance.layout.fillMaxHeight
+import androidx.glance.layout.fillMaxSize
+import androidx.glance.layout.height
+import androidx.glance.layout.padding
+import androidx.glance.layout.size
+import androidx.glance.layout.width
+import androidx.glance.semantics.contentDescription
+import androidx.glance.semantics.semantics
+import androidx.glance.state.PreferencesGlanceStateDefinition
+import androidx.glance.text.FontWeight
+import androidx.glance.text.Text
+import androidx.glance.text.TextAlign
+import androidx.glance.text.TextStyle
+import androidx.glance.unit.ColorProvider
+import de.jeanlucmakiola.calendula.MainActivity
+import de.jeanlucmakiola.calendula.R
+import de.jeanlucmakiola.calendula.ui.common.CalendarView
+import de.jeanlucmakiola.calendula.ui.common.eventAccent
+import de.jeanlucmakiola.calendula.ui.common.eventFill
+import de.jeanlucmakiola.calendula.widget.CalendulaWidgetReceiver
+import de.jeanlucmakiola.calendula.widget.CalendulaWidgetRoot
+import de.jeanlucmakiola.calendula.widget.DaysWidgetData
+import de.jeanlucmakiola.calendula.widget.WidgetAppearance
+import de.jeanlucmakiola.calendula.widget.loadDays
+import de.jeanlucmakiola.calendula.widget.weekOf
+import de.jeanlucmakiola.calendula.widget.widgetAppearance
+import de.jeanlucmakiola.calendula.widget.widgetLocale
+import de.jeanlucmakiola.calendula.widget.widgetCardColor
+import de.jeanlucmakiola.calendula.widget.widgetLoadContext
+import de.jeanlucmakiola.floret.glance.WidgetData
+import de.jeanlucmakiola.floret.glance.WidgetMessage
+import de.jeanlucmakiola.floret.glance.currentRefreshStamp
+import de.jeanlucmakiola.floret.glance.refreshStamp
+import de.jeanlucmakiola.floret.glance.themedColor
+import de.jeanlucmakiola.floret.locale.localizedDateFormatter
+import de.jeanlucmakiola.floret.prefs.ThemeMode
+import kotlinx.datetime.DateTimeUnit
+import kotlinx.datetime.LocalDate
+import kotlinx.datetime.plus
+import kotlinx.datetime.toJavaDayOfWeek
+import kotlinx.datetime.toJavaLocalDate
+import java.time.format.TextStyle as JavaTextStyle
+
+/**
+ * 4×1 week strip (#436): the current week's seven days from the app's week
+ * start, today filled, a dot per event colour under each day. A day opens
+ * its day view.
+ */
+class WeekStripWidget : GlanceAppWidget() {
+
+ override val stateDefinition = PreferencesGlanceStateDefinition
+
+ override val sizeMode = SizeMode.Single
+
+ override val previewSizeMode = previewSizeOf(WidgetKind.WEEK_STRIP)
+
+ override suspend fun providePreview(context: Context, widgetCategory: Int) =
+ provideSamplePreview(context, WidgetKind.WEEK_STRIP)
+
+ override suspend fun provideGlance(context: Context, id: GlanceId) {
+ val widgetData = WidgetData(context.loadWeek(id), refreshStamp(context, id))
+ provideContent {
+ val loaded = widgetData.current(currentRefreshStamp()) { context.loadWeek(id) }
+ WeekStripWidgetContent(loaded.content, currentState().widgetAppearance, loaded.appTheme)
+ }
+ }
+}
+
+class WeekStripWidgetReceiver : CalendulaWidgetReceiver() {
+ override val glanceAppWidget: GlanceAppWidget = WeekStripWidget()
+}
+
+private suspend fun Context.loadWeek(id: GlanceId) = loadDays(widgetLoadContext(id), ::weekOf)
+
+/** Most dots a day shows; more colours than this read as noise at this size. */
+private const val MAX_DOTS = 3
+
+private val DayGap = 4.dp
+private val DotSize = 5.dp
+
+@Composable
+internal fun WeekStripWidgetContent(data: DaysWidgetData, appearance: WidgetAppearance, appTheme: ThemeMode) {
+ CalendulaWidgetRoot(appearance, appTheme) {
+ when (data) {
+ DaysWidgetData.NeedsPermission ->
+ WidgetMessage(LocalContext.current.getString(R.string.widget_needs_permission))
+ is DaysWidgetData.Ready -> Row(
+ modifier = GlanceModifier.fillMaxSize().padding(6.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ for (i in 0 until 7) {
+ if (i > 0) Spacer(GlanceModifier.width(DayGap))
+ Box(modifier = GlanceModifier.defaultWeight().fillMaxHeight()) {
+ DayTile(data.firstDay.plus(i, DateTimeUnit.DAY), data)
+ }
+ }
+ }
+ }
+ }
+}
+
+@Composable
+private fun DayTile(date: LocalDate, data: DaysWidgetData.Ready) {
+ val context = LocalContext.current
+ val isToday = date == data.today
+ val container = if (isToday) GlanceTheme.colors.primary else widgetCardColor()
+ val ink = when {
+ isToday -> GlanceTheme.colors.onPrimary
+ date < data.today -> GlanceTheme.colors.onSurfaceVariant
+ else -> GlanceTheme.colors.onSurface
+ }
+ val locale = widgetLocale()
+ val colors = data.eventsOn(date).map { it.color }.distinct()
+ val label = localizedDateFormatter(locale, "EEEEdMMMM").format(date.toJavaLocalDate())
+ Column(
+ modifier = GlanceModifier
+ .fillMaxSize()
+ .cornerRadius(14.dp)
+ .background(container)
+ .clickable(actionStartActivity(MainActivity.openDateIntent(context, date, CalendarView.Week)))
+ .semantics {
+ contentDescription = if (colors.isEmpty()) label else context.resources.getQuantityString(
+ R.plurals.widget_day_with_events, data.eventsOn(date).size, label, data.eventsOn(date).size,
+ )
+ },
+ horizontalAlignment = Alignment.CenterHorizontally,
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ Text(
+ text = date.dayOfWeek.toJavaDayOfWeek().getDisplayName(JavaTextStyle.SHORT, locale),
+ maxLines = 1,
+ style = TextStyle(color = ink, fontSize = 11.sp, textAlign = TextAlign.Center),
+ )
+ Text(
+ text = date.day.toString(),
+ maxLines = 1,
+ style = TextStyle(color = ink, fontSize = 17.sp, fontWeight = FontWeight.Medium, textAlign = TextAlign.Center),
+ )
+ Spacer(GlanceModifier.height(3.dp))
+ // A dot row is always drawn, so the numbers line up across days.
+ Row(modifier = GlanceModifier.height(DotSize)) {
+ colors.take(MAX_DOTS).forEachIndexed { index, color ->
+ if (index > 0) Spacer(GlanceModifier.width(3.dp))
+ Dot(dotColor(color, isToday, data.soften))
+ }
+ }
+ }
+}
+
+/** A mark on the tile: the accent tone on a card, the lighter fill tone on today's primary. */
+@Composable
+private fun dotColor(color: Int, onPrimary: Boolean, soften: Boolean): ColorProvider = if (onPrimary) {
+ themedColor(day = eventFill(color, dark = false, soften), night = eventFill(color, dark = true, soften))
+} else {
+ themedColor(day = eventAccent(color, dark = false, soften), night = eventAccent(color, dark = true, soften))
+}
+
+@Composable
+private fun Dot(color: ColorProvider) {
+ Image(
+ provider = ImageProvider(R.drawable.widget_dot),
+ contentDescription = null,
+ colorFilter = ColorFilter.tint(color),
+ modifier = GlanceModifier.size(DotSize),
+ )
+}
diff --git a/app/src/main/res/drawable/ic_widget_refresh.xml b/app/src/main/res/drawable/ic_widget_refresh.xml
deleted file mode 100644
index ee48912..0000000
--- a/app/src/main/res/drawable/ic_widget_refresh.xml
+++ /dev/null
@@ -1,6 +0,0 @@
-
-
-
diff --git a/app/src/main/res/drawable/ic_widget_today.xml b/app/src/main/res/drawable/ic_widget_today.xml
deleted file mode 100644
index 075ba88..0000000
--- a/app/src/main/res/drawable/ic_widget_today.xml
+++ /dev/null
@@ -1,6 +0,0 @@
-
-
-
diff --git a/app/src/main/res/drawable/preview_stripe.xml b/app/src/main/res/drawable/preview_stripe.xml
index 9351a43..f1be56b 100644
--- a/app/src/main/res/drawable/preview_stripe.xml
+++ b/app/src/main/res/drawable/preview_stripe.xml
@@ -1,6 +1,6 @@
-
+
diff --git a/app/src/main/res/drawable/preview_widget_cell_bg.xml b/app/src/main/res/drawable/preview_widget_cell_bg.xml
new file mode 100644
index 0000000..2d8aea5
--- /dev/null
+++ b/app/src/main/res/drawable/preview_widget_cell_bg.xml
@@ -0,0 +1,6 @@
+
+
+
+
+
diff --git a/app/src/main/res/drawable/preview_widget_event_bg.xml b/app/src/main/res/drawable/preview_widget_event_bg.xml
new file mode 100644
index 0000000..0660e55
--- /dev/null
+++ b/app/src/main/res/drawable/preview_widget_event_bg.xml
@@ -0,0 +1,6 @@
+
+
+
+
+
diff --git a/app/src/main/res/drawable/preview_widget_outside_bg.xml b/app/src/main/res/drawable/preview_widget_outside_bg.xml
new file mode 100644
index 0000000..8882d92
--- /dev/null
+++ b/app/src/main/res/drawable/preview_widget_outside_bg.xml
@@ -0,0 +1,6 @@
+
+
+
+
+
diff --git a/app/src/main/res/drawable/preview_widget_today_bg.xml b/app/src/main/res/drawable/preview_widget_today_bg.xml
new file mode 100644
index 0000000..6fe3b70
--- /dev/null
+++ b/app/src/main/res/drawable/preview_widget_today_bg.xml
@@ -0,0 +1,6 @@
+
+
+
+
+
diff --git a/app/src/main/res/drawable/widget_dot.xml b/app/src/main/res/drawable/widget_dot.xml
new file mode 100644
index 0000000..3cd90b3
--- /dev/null
+++ b/app/src/main/res/drawable/widget_dot.xml
@@ -0,0 +1,5 @@
+
+
+
+
diff --git a/app/src/main/res/drawable/widget_month_bar_bg.xml b/app/src/main/res/drawable/widget_month_bar_bg.xml
new file mode 100644
index 0000000..eb18a82
--- /dev/null
+++ b/app/src/main/res/drawable/widget_month_bar_bg.xml
@@ -0,0 +1,5 @@
+
+
+
+
+
diff --git a/app/src/main/res/drawable/widget_month_cell_bg.xml b/app/src/main/res/drawable/widget_month_cell_bg.xml
new file mode 100644
index 0000000..34615d9
--- /dev/null
+++ b/app/src/main/res/drawable/widget_month_cell_bg.xml
@@ -0,0 +1,5 @@
+
+
+
+
+
diff --git a/app/src/main/res/drawable/widget_month_today_bg.xml b/app/src/main/res/drawable/widget_month_today_bg.xml
new file mode 100644
index 0000000..3626003
--- /dev/null
+++ b/app/src/main/res/drawable/widget_month_today_bg.xml
@@ -0,0 +1,4 @@
+
+
+
+
diff --git a/app/src/main/res/layout/widget_month_bar_1.xml b/app/src/main/res/layout/widget_month_bar_1.xml
new file mode 100644
index 0000000..38b7c5a
--- /dev/null
+++ b/app/src/main/res/layout/widget_month_bar_1.xml
@@ -0,0 +1,26 @@
+
+
+
+
+
+
+
diff --git a/app/src/main/res/layout/widget_month_bar_2.xml b/app/src/main/res/layout/widget_month_bar_2.xml
new file mode 100644
index 0000000..5310c85
--- /dev/null
+++ b/app/src/main/res/layout/widget_month_bar_2.xml
@@ -0,0 +1,26 @@
+
+
+
+
+
+
+
diff --git a/app/src/main/res/layout/widget_month_bar_3.xml b/app/src/main/res/layout/widget_month_bar_3.xml
new file mode 100644
index 0000000..75eb3c3
--- /dev/null
+++ b/app/src/main/res/layout/widget_month_bar_3.xml
@@ -0,0 +1,26 @@
+
+
+
+
+
+
+
diff --git a/app/src/main/res/layout/widget_month_bar_4.xml b/app/src/main/res/layout/widget_month_bar_4.xml
new file mode 100644
index 0000000..bb6557e
--- /dev/null
+++ b/app/src/main/res/layout/widget_month_bar_4.xml
@@ -0,0 +1,26 @@
+
+
+
+
+
+
+
diff --git a/app/src/main/res/layout/widget_month_bar_5.xml b/app/src/main/res/layout/widget_month_bar_5.xml
new file mode 100644
index 0000000..6cd75a8
--- /dev/null
+++ b/app/src/main/res/layout/widget_month_bar_5.xml
@@ -0,0 +1,26 @@
+
+
+
+
+
+
+
diff --git a/app/src/main/res/layout/widget_month_bar_6.xml b/app/src/main/res/layout/widget_month_bar_6.xml
new file mode 100644
index 0000000..e0cf5f2
--- /dev/null
+++ b/app/src/main/res/layout/widget_month_bar_6.xml
@@ -0,0 +1,26 @@
+
+
+
+
+
+
+
diff --git a/app/src/main/res/layout/widget_month_bar_7.xml b/app/src/main/res/layout/widget_month_bar_7.xml
new file mode 100644
index 0000000..17d7b32
--- /dev/null
+++ b/app/src/main/res/layout/widget_month_bar_7.xml
@@ -0,0 +1,26 @@
+
+
+
+
+
+
+
diff --git a/app/src/main/res/layout/widget_month_gap_1.xml b/app/src/main/res/layout/widget_month_gap_1.xml
new file mode 100644
index 0000000..6d18a12
--- /dev/null
+++ b/app/src/main/res/layout/widget_month_gap_1.xml
@@ -0,0 +1,5 @@
+
+
diff --git a/app/src/main/res/layout/widget_month_gap_2.xml b/app/src/main/res/layout/widget_month_gap_2.xml
new file mode 100644
index 0000000..5d6f690
--- /dev/null
+++ b/app/src/main/res/layout/widget_month_gap_2.xml
@@ -0,0 +1,5 @@
+
+
diff --git a/app/src/main/res/layout/widget_month_gap_3.xml b/app/src/main/res/layout/widget_month_gap_3.xml
new file mode 100644
index 0000000..d73cef0
--- /dev/null
+++ b/app/src/main/res/layout/widget_month_gap_3.xml
@@ -0,0 +1,5 @@
+
+
diff --git a/app/src/main/res/layout/widget_month_gap_4.xml b/app/src/main/res/layout/widget_month_gap_4.xml
new file mode 100644
index 0000000..b0ddf81
--- /dev/null
+++ b/app/src/main/res/layout/widget_month_gap_4.xml
@@ -0,0 +1,5 @@
+
+
diff --git a/app/src/main/res/layout/widget_month_gap_5.xml b/app/src/main/res/layout/widget_month_gap_5.xml
new file mode 100644
index 0000000..d36a2aa
--- /dev/null
+++ b/app/src/main/res/layout/widget_month_gap_5.xml
@@ -0,0 +1,5 @@
+
+
diff --git a/app/src/main/res/layout/widget_month_gap_6.xml b/app/src/main/res/layout/widget_month_gap_6.xml
new file mode 100644
index 0000000..6da1869
--- /dev/null
+++ b/app/src/main/res/layout/widget_month_gap_6.xml
@@ -0,0 +1,5 @@
+
+
diff --git a/app/src/main/res/layout/widget_month_gap_7.xml b/app/src/main/res/layout/widget_month_gap_7.xml
new file mode 100644
index 0000000..6ba0099
--- /dev/null
+++ b/app/src/main/res/layout/widget_month_gap_7.xml
@@ -0,0 +1,5 @@
+
+
diff --git a/app/src/main/res/layout/widget_month_grid.xml b/app/src/main/res/layout/widget_month_grid.xml
new file mode 100644
index 0000000..bdf0ebf
--- /dev/null
+++ b/app/src/main/res/layout/widget_month_grid.xml
@@ -0,0 +1,6 @@
+
+
diff --git a/app/src/main/res/layout/widget_month_lane.xml b/app/src/main/res/layout/widget_month_lane.xml
new file mode 100644
index 0000000..57a8df1
--- /dev/null
+++ b/app/src/main/res/layout/widget_month_lane.xml
@@ -0,0 +1,8 @@
+
+
diff --git a/app/src/main/res/layout/widget_month_more.xml b/app/src/main/res/layout/widget_month_more.xml
new file mode 100644
index 0000000..63f9b17
--- /dev/null
+++ b/app/src/main/res/layout/widget_month_more.xml
@@ -0,0 +1,11 @@
+
+
diff --git a/app/src/main/res/layout/widget_month_week.xml b/app/src/main/res/layout/widget_month_week.xml
new file mode 100644
index 0000000..60942ab
--- /dev/null
+++ b/app/src/main/res/layout/widget_month_week.xml
@@ -0,0 +1,360 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/res/layout/widget_preview_agenda.xml b/app/src/main/res/layout/widget_preview_agenda.xml
index 75721fe..aceb98d 100644
--- a/app/src/main/res/layout/widget_preview_agenda.xml
+++ b/app/src/main/res/layout/widget_preview_agenda.xml
@@ -1,124 +1,186 @@
-
+
-
-
-
-
+ android:orientation="vertical"
+ android:padding="8dp"
+ tools:ignore="HardcodedText,NestedWeights,DisableBaselineAlignment,SmallSp,TooManyViews,UselessParent">
-
+ android:gravity="center_vertical"
+ android:orientation="horizontal">
+
+
+
+
+
+
+
+ android:layout_marginTop="0dp"
+ android:background="@drawable/preview_widget_event_bg"
+ android:backgroundTint="@color/widget_preview_event_blue"
+ android:orientation="vertical"
+ android:paddingHorizontal="10dp"
+ android:paddingVertical="3dp">
+ android:ellipsize="end"
+ android:maxLines="1"
+ android:text="@string/settings_past_events_sample_morning"
+ android:textColor="@color/widget_preview_on_event_light"
+ android:textSize="12sp"
+ android:textStyle="bold" />
+ android:ellipsize="end"
+ android:maxLines="1"
+ android:text="9:30 – 10:00"
+ android:textColor="@color/widget_preview_on_event_light"
+ android:textSize="11sp" />
+
+
+
+
+
+
+
+
+
-
+ android:layout_marginTop="6dp"
+ android:orientation="horizontal">
+
+
+ android:layout_marginTop="0dp"
+ android:background="@drawable/preview_widget_event_bg"
+ android:backgroundTint="@color/widget_preview_event_orange"
+ android:orientation="vertical"
+ android:paddingHorizontal="10dp"
+ android:paddingVertical="3dp">
+ android:ellipsize="end"
+ android:maxLines="1"
+ android:text="@string/widget_preview_sample_trip"
+ android:textColor="@color/widget_preview_on_event_light"
+ android:textSize="12sp"
+ android:textStyle="bold" />
+ android:ellipsize="end"
+ android:maxLines="1"
+ android:text="@string/event_detail_all_day"
+ android:textColor="@color/widget_preview_on_event_light"
+ android:textSize="11sp" />
-
-
-
-
-
-
-
diff --git a/app/src/main/res/layout/widget_preview_countdown.xml b/app/src/main/res/layout/widget_preview_countdown.xml
new file mode 100644
index 0000000..966dbc3
--- /dev/null
+++ b/app/src/main/res/layout/widget_preview_countdown.xml
@@ -0,0 +1,49 @@
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/res/layout/widget_preview_date.xml b/app/src/main/res/layout/widget_preview_date.xml
new file mode 100644
index 0000000..a5d8409
--- /dev/null
+++ b/app/src/main/res/layout/widget_preview_date.xml
@@ -0,0 +1,44 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/res/layout/widget_preview_month.xml b/app/src/main/res/layout/widget_preview_month.xml
index 90c2d0c..910375a 100644
--- a/app/src/main/res/layout/widget_preview_month.xml
+++ b/app/src/main/res/layout/widget_preview_month.xml
@@ -1,114 +1,776 @@
-
+
+ android:orientation="vertical"
+ android:padding="8dp"
+ tools:ignore="HardcodedText,NestedWeights,DisableBaselineAlignment,SmallSp,TooManyViews,UselessParent">
-
-
+ android:gravity="center_vertical"
+ android:orientation="horizontal">
-
+
+
+
+
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/res/layout/widget_preview_month_day.xml b/app/src/main/res/layout/widget_preview_month_day.xml
new file mode 100644
index 0000000..451a6cd
--- /dev/null
+++ b/app/src/main/res/layout/widget_preview_month_day.xml
@@ -0,0 +1,518 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/res/layout/widget_preview_quick_add.xml b/app/src/main/res/layout/widget_preview_quick_add.xml
new file mode 100644
index 0000000..a896785
--- /dev/null
+++ b/app/src/main/res/layout/widget_preview_quick_add.xml
@@ -0,0 +1,27 @@
+
+
+
+
+
+
+
+
diff --git a/app/src/main/res/layout/widget_preview_today.xml b/app/src/main/res/layout/widget_preview_today.xml
new file mode 100644
index 0000000..61877fa
--- /dev/null
+++ b/app/src/main/res/layout/widget_preview_today.xml
@@ -0,0 +1,84 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/res/layout/widget_preview_upcoming.xml b/app/src/main/res/layout/widget_preview_upcoming.xml
new file mode 100644
index 0000000..b714508
--- /dev/null
+++ b/app/src/main/res/layout/widget_preview_upcoming.xml
@@ -0,0 +1,89 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/res/layout/widget_preview_week_strip.xml b/app/src/main/res/layout/widget_preview_week_strip.xml
new file mode 100644
index 0000000..e2a0717
--- /dev/null
+++ b/app/src/main/res/layout/widget_preview_week_strip.xml
@@ -0,0 +1,202 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/res/values-night-v31/widget_preview_colors.xml b/app/src/main/res/values-night-v31/widget_preview_colors.xml
index 26162c8..f987341 100644
--- a/app/src/main/res/values-night-v31/widget_preview_colors.xml
+++ b/app/src/main/res/values-night-v31/widget_preview_colors.xml
@@ -1,6 +1,8 @@
- @android:color/system_neutral1_900
+ @android:color/system_accent2_700
+ @android:color/system_accent2_900
+ @android:color/system_neutral2_600
@android:color/system_neutral1_50
@android:color/system_neutral2_200
@android:color/system_accent1_200
diff --git a/app/src/main/res/values-night/widget_preview_colors.xml b/app/src/main/res/values-night/widget_preview_colors.xml
index ca6b3f7..7df62c3 100644
--- a/app/src/main/res/values-night/widget_preview_colors.xml
+++ b/app/src/main/res/values-night/widget_preview_colors.xml
@@ -1,6 +1,8 @@
- #101316
+ #2B3742
+ #1C252D
+ #3A4651
#E1E3E6
#A8ADB2
#A3CBE2
diff --git a/app/src/main/res/values-v31/widget_preview_colors.xml b/app/src/main/res/values-v31/widget_preview_colors.xml
index 0eb2ece..f706cb2 100644
--- a/app/src/main/res/values-v31/widget_preview_colors.xml
+++ b/app/src/main/res/values-v31/widget_preview_colors.xml
@@ -1,6 +1,8 @@
- @android:color/system_neutral1_50
+ @android:color/system_accent2_100
+ @android:color/system_accent2_10
+ @android:color/system_neutral2_300
@android:color/system_neutral1_900
@android:color/system_neutral2_700
@android:color/system_accent1_600
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index 0ef6379..1e940ae 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -435,12 +435,94 @@
Upcoming
Calendula agenda
Calendula month
+ Calendula quick add
+ Calendula date
+ Calendula week strip
+
+
+ - %1$s, %2$d event
+ - %1$s, %2$d events
+
+ Calendula upcoming
+ No upcoming events
+ Calendula today
+ Nothing left today
+ Calendula countdown
+ Now
+ Ending now
+ Tomorrow
+ Ends tomorrow
+
+ Until %1$s
+
+ - in %d min
+ - in %d min
+
+
+ - in %d h
+ - in %d h
+
+
+ - in %d day
+ - in %d days
+
+
+ - ends in %d min
+ - ends in %d min
+
+
+ - ends in %d h
+ - ends in %d h
+
+
+ - ends in %d day
+ - ends in %d days
+
+ Tap to choose an event to count down to
+
+ %1$s is over
+ Count down to
+ Next event
+ Chosen event
+ The next timed event in this widget\'s calendars, or the one running now. All-day and declined events are skipped.
+ Event
+ None chosen yet
+ Search events
+ No events in the coming year match.
+ Couldn\'t load your events.
+
+ %1$s · repeats
+ Calendula month and day
+ No events
Refresh
New event
Open Calendula to grant calendar access
Previous month
Next month
Today
+ Background
+ Solid
+ Tinted
+ Transparent
+ Follow app
+ Content
+ Time range
+ Text size
+ Couldn\'t read this widget\'s settings.
+ Preview unavailable
+ Couldn\'t save the widget. Try again.
+
+ %1$s %2$d
+
+ %1$s · %2$s
+ Calendars
+ The calendars this widget shows. Calendars you hid in the app stay hidden here too.
+ All calendars
+
+
+ - %1$d of %2$d calendar
+ - %1$d of %2$d calendars
+
Calendars
@@ -539,6 +621,9 @@
Team review
Lunch with Robin
Choir practice
+
+ Weekend trip
+ Dentist
Agenda
Agenda range
How far ahead the Agenda screen lists events.
@@ -554,7 +639,7 @@
Event text %1$d%%
Always show today
- Keep today at the top of the agenda and its widget, even once nothing is left today.
+ Keep today at the top of the agenda, even once nothing is left today. Each widget has its own setting.
Range bar
Show a bar at the top of the agenda naming the dates shown, with a button to switch the range for the session
Today
@@ -645,12 +730,15 @@
Default fields and behaviour
Reminders and delivery
Contact birthdays & anniversaries
- Agenda widget, Quick Settings tile
+ Your widgets, Quick Settings tile
Export, import, automatic backup
Widgets & tiles
- Settings for the home-screen widgets and the Quick Settings tile. Add a widget by long-pressing your home screen.
+ Each widget has its own look and options. Tap one below, or long-press it on your home screen and choose Edit. Add a widget by long-pressing your home screen.
+ Each widget has its own look and options. Tap one below to change it. Add a widget by long-pressing your home screen.
+ On your home screen
+ No Calendula widgets on your home screen yet.
Backup & restore
diff --git a/app/src/main/res/values/themes.xml b/app/src/main/res/values/themes.xml
index fed97fa..a161487 100644
--- a/app/src/main/res/values/themes.xml
+++ b/app/src/main/res/values/themes.xml
@@ -7,4 +7,10 @@
- @color/window_background
- true
+
+
+
diff --git a/app/src/main/res/values/widget_preview_colors.xml b/app/src/main/res/values/widget_preview_colors.xml
index 98d328b..b584476 100644
--- a/app/src/main/res/values/widget_preview_colors.xml
+++ b/app/src/main/res/values/widget_preview_colors.xml
@@ -1,11 +1,21 @@
-
+
- #FBFCFE
+ #D6E4F0
+ #EEF3F8
+ #C2CCD6
#191C1F
#6E7479
#3B5364
#FFFFFF
+ #039BE5
+ #33B679
+ #8E24AA
+ #F4511E
+ #D50000
+ #000000
+ #FFFFFF
diff --git a/app/src/main/res/values/widget_preview_styles.xml b/app/src/main/res/values/widget_preview_styles.xml
new file mode 100644
index 0000000..0362d0f
--- /dev/null
+++ b/app/src/main/res/values/widget_preview_styles.xml
@@ -0,0 +1,29 @@
+
+
+
+
+
+
+
diff --git a/app/src/main/res/xml/appwidget_info_agenda.xml b/app/src/main/res/xml/appwidget_info_agenda.xml
index 12abbe3..b8c707c 100644
--- a/app/src/main/res/xml/appwidget_info_agenda.xml
+++ b/app/src/main/res/xml/appwidget_info_agenda.xml
@@ -8,6 +8,8 @@
android:minResizeHeight="110dp"
android:resizeMode="horizontal|vertical"
android:widgetCategory="home_screen"
+ android:configure="de.jeanlucmakiola.calendula.widget.config.WidgetConfigActivity"
+ android:widgetFeatures="reconfigurable"
android:description="@string/widget_agenda_label"
android:updatePeriodMillis="1800000"
android:previewLayout="@layout/widget_preview_agenda"
diff --git a/app/src/main/res/xml/appwidget_info_countdown.xml b/app/src/main/res/xml/appwidget_info_countdown.xml
new file mode 100644
index 0000000..e1a1071
--- /dev/null
+++ b/app/src/main/res/xml/appwidget_info_countdown.xml
@@ -0,0 +1,16 @@
+
+
diff --git a/app/src/main/res/xml/appwidget_info_date.xml b/app/src/main/res/xml/appwidget_info_date.xml
new file mode 100644
index 0000000..10b8368
--- /dev/null
+++ b/app/src/main/res/xml/appwidget_info_date.xml
@@ -0,0 +1,14 @@
+
+
diff --git a/app/src/main/res/xml/appwidget_info_month.xml b/app/src/main/res/xml/appwidget_info_month.xml
index cfa7acb..075aa51 100644
--- a/app/src/main/res/xml/appwidget_info_month.xml
+++ b/app/src/main/res/xml/appwidget_info_month.xml
@@ -8,6 +8,8 @@
android:minResizeHeight="150dp"
android:resizeMode="horizontal|vertical"
android:widgetCategory="home_screen"
+ android:configure="de.jeanlucmakiola.calendula.widget.config.WidgetConfigActivity"
+ android:widgetFeatures="reconfigurable"
android:description="@string/widget_month_label"
android:updatePeriodMillis="1800000"
android:previewLayout="@layout/widget_preview_month"
diff --git a/app/src/main/res/xml/appwidget_info_month_day.xml b/app/src/main/res/xml/appwidget_info_month_day.xml
new file mode 100644
index 0000000..244dbc7
--- /dev/null
+++ b/app/src/main/res/xml/appwidget_info_month_day.xml
@@ -0,0 +1,16 @@
+
+
diff --git a/app/src/main/res/xml/appwidget_info_quick_add.xml b/app/src/main/res/xml/appwidget_info_quick_add.xml
new file mode 100644
index 0000000..933ce6d
--- /dev/null
+++ b/app/src/main/res/xml/appwidget_info_quick_add.xml
@@ -0,0 +1,14 @@
+
+
diff --git a/app/src/main/res/xml/appwidget_info_today.xml b/app/src/main/res/xml/appwidget_info_today.xml
new file mode 100644
index 0000000..753674b
--- /dev/null
+++ b/app/src/main/res/xml/appwidget_info_today.xml
@@ -0,0 +1,18 @@
+
+
diff --git a/app/src/main/res/xml/appwidget_info_upcoming.xml b/app/src/main/res/xml/appwidget_info_upcoming.xml
new file mode 100644
index 0000000..e80cddb
--- /dev/null
+++ b/app/src/main/res/xml/appwidget_info_upcoming.xml
@@ -0,0 +1,16 @@
+
+
diff --git a/app/src/main/res/xml/appwidget_info_week_strip.xml b/app/src/main/res/xml/appwidget_info_week_strip.xml
new file mode 100644
index 0000000..1c17bff
--- /dev/null
+++ b/app/src/main/res/xml/appwidget_info_week_strip.xml
@@ -0,0 +1,16 @@
+
+
diff --git a/app/src/test/java/de/jeanlucmakiola/calendula/data/prefs/SettingsPrefsTest.kt b/app/src/test/java/de/jeanlucmakiola/calendula/data/prefs/SettingsPrefsTest.kt
index 44101d1..74a425b 100644
--- a/app/src/test/java/de/jeanlucmakiola/calendula/data/prefs/SettingsPrefsTest.kt
+++ b/app/src/test/java/de/jeanlucmakiola/calendula/data/prefs/SettingsPrefsTest.kt
@@ -165,15 +165,12 @@ class SettingsPrefsTest {
}
@Test
- fun `agenda ranges default to month and round-trip independently`(@TempDir tempDir: Path) = runTest {
+ fun `agenda screen range defaults to month and round-trips`(@TempDir tempDir: Path) = runTest {
val prefs = testSettings(tempDir)
assertThat(prefs.agendaScreenRange.first()).isEqualTo(AgendaRange.Month)
- assertThat(prefs.agendaWidgetRange.first()).isEqualTo(AgendaRange.Month)
prefs.setAgendaScreenRange(AgendaRange.Custom(14))
- prefs.setAgendaWidgetRange(AgendaRange.Week)
assertThat(prefs.agendaScreenRange.first()).isEqualTo(AgendaRange.Custom(14))
- assertThat(prefs.agendaWidgetRange.first()).isEqualTo(AgendaRange.Week)
}
@Test
diff --git a/app/src/test/java/de/jeanlucmakiola/calendula/widget/MonthWindowCenterTest.kt b/app/src/test/java/de/jeanlucmakiola/calendula/widget/MonthWindowCenterTest.kt
new file mode 100644
index 0000000..7dfca3f
--- /dev/null
+++ b/app/src/test/java/de/jeanlucmakiola/calendula/widget/MonthWindowCenterTest.kt
@@ -0,0 +1,23 @@
+package de.jeanlucmakiola.calendula.widget
+
+import com.google.common.truth.Truth.assertThat
+import org.junit.jupiter.api.Test
+
+class MonthWindowCenterTest {
+
+ @Test
+ fun `the loaded window always reaches two months past the shown one`() {
+ for (index in -40..40) {
+ val center = monthWindowCenter(index)
+ assertThat(index - 2).isAtLeast(center - 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))
+ }
+}
diff --git a/app/src/test/java/de/jeanlucmakiola/calendula/widget/WeekOfTest.kt b/app/src/test/java/de/jeanlucmakiola/calendula/widget/WeekOfTest.kt
new file mode 100644
index 0000000..c4c0af4
--- /dev/null
+++ b/app/src/test/java/de/jeanlucmakiola/calendula/widget/WeekOfTest.kt
@@ -0,0 +1,18 @@
+package de.jeanlucmakiola.calendula.widget
+
+import com.google.common.truth.Truth.assertThat
+import kotlinx.datetime.DayOfWeek
+import kotlinx.datetime.LocalDate
+import org.junit.jupiter.api.Test
+
+class WeekOfTest {
+
+ @Test
+ fun `the week starts on the configured day and holds today`() {
+ val wednesday = LocalDate(2026, 10, 21)
+ assertThat(weekOf(wednesday, DayOfWeek.MONDAY)).isEqualTo(LocalDate(2026, 10, 19)..LocalDate(2026, 10, 25))
+ assertThat(weekOf(wednesday, DayOfWeek.SUNDAY)).isEqualTo(LocalDate(2026, 10, 18)..LocalDate(2026, 10, 24))
+ assertThat(weekOf(wednesday, DayOfWeek.WEDNESDAY).start).isEqualTo(wednesday)
+ assertThat(weekOf(wednesday, DayOfWeek.THURSDAY).start).isEqualTo(LocalDate(2026, 10, 15))
+ }
+}
diff --git a/app/src/test/java/de/jeanlucmakiola/calendula/widget/WidgetRolloverSchedulerTest.kt b/app/src/test/java/de/jeanlucmakiola/calendula/widget/WidgetRolloverSchedulerTest.kt
deleted file mode 100644
index 8242073..0000000
--- a/app/src/test/java/de/jeanlucmakiola/calendula/widget/WidgetRolloverSchedulerTest.kt
+++ /dev/null
@@ -1,189 +0,0 @@
-package de.jeanlucmakiola.calendula.widget
-
-import android.content.Intent
-import com.google.common.truth.Truth.assertThat
-import kotlinx.datetime.LocalDate
-import kotlinx.datetime.LocalDateTime
-import kotlinx.datetime.TimeZone
-import kotlinx.datetime.atStartOfDayIn
-import kotlinx.datetime.toInstant
-import kotlinx.datetime.toLocalDateTime
-import org.junit.jupiter.api.Test
-import kotlin.time.Duration
-import kotlin.time.Duration.Companion.hours
-import kotlin.time.Duration.Companion.minutes
-import kotlin.time.Instant
-
-/**
- * The rollover alarm is what makes the widgets stop highlighting yesterday
- * (#228), so the "when is the next local midnight" arithmetic is the one piece
- * worth pinning down — especially where midnight is not 00:00.
- */
-class WidgetRolloverSchedulerTest {
-
- private val berlin = TimeZone.of("Europe/Berlin")
-
- private fun at(local: String, zone: TimeZone): Instant =
- LocalDateTime.parse(local).toInstant(zone)
-
- private fun nextRollover(local: String, zone: TimeZone = berlin): Instant =
- WidgetRolloverScheduler.nextRolloverAt(at(local, zone), zone)
-
- // --- the ordinary day ----------------------------------------------------
-
- @Test
- fun `midday rolls over at the coming midnight`() {
- val next = nextRollover("2026-08-27T12:00:00")
- assertThat(next).isEqualTo(at("2026-08-28T00:00:05", berlin))
- }
-
- @Test
- fun `a second before midnight still targets tonight, not tomorrow night`() {
- val next = nextRollover("2026-08-27T23:59:59")
- assertThat(next).isEqualTo(at("2026-08-28T00:00:05", berlin))
- }
-
- @Test
- fun `at midnight exactly the target is the next day, never the current instant`() {
- // Re-arming after a firing must move a whole day on, or the widget wakes
- // itself in a tight loop.
- val now = at("2026-08-28T00:00:00", berlin)
- val next = WidgetRolloverScheduler.nextRolloverAt(now, berlin)
- assertThat(next).isEqualTo(at("2026-08-29T00:00:05", berlin))
- assertThat(next - now).isGreaterThan(Duration.ZERO)
- }
-
- @Test
- fun `re-arming from the slack instant itself moves a full day on`() {
- // What actually happens in practice: the receiver runs at midnight + slack.
- val now = at("2026-08-28T00:00:05", berlin)
- assertThat(WidgetRolloverScheduler.nextRolloverAt(now, berlin))
- .isEqualTo(at("2026-08-29T00:00:05", berlin))
- }
-
- @Test
- fun `the result is always in the future for every minute of a day`() {
- val zone = berlin
- // Spans Berlin's 2024 spring-forward: the likeliest source of a target
- // in the past, i.e. an alarm that fires immediately, forever.
- var probe = LocalDateTime.parse("2024-03-29T00:00:00").toInstant(zone)
- val end = LocalDateTime.parse("2024-04-01T00:00:00").toInstant(zone)
- while (probe < end) {
- assertThat(WidgetRolloverScheduler.nextRolloverAt(probe, zone)).isGreaterThan(probe)
- probe += 1.minutes
- }
- }
-
- // --- daylight saving -----------------------------------------------------
-
- @Test
- fun `spring forward keeps the rollover one day away, not one hour short`() {
- // Berlin skipped 02:00-03:00 on 31 March 2024, so that day was 23h long.
- // A rollover computed as "now + 24h" would land at 01:00 on 1 April.
- val now = at("2024-03-30T12:00:00", berlin)
- val next = WidgetRolloverScheduler.nextRolloverAt(now, berlin)
- assertThat(next).isEqualTo(at("2024-03-31T00:00:05", berlin))
- assertThat(next - now).isLessThan(24.hours)
- }
-
- @Test
- fun `fall back does not overshoot into the repeated hour`() {
- // Berlin repeated 02:00-03:00 on 27 October 2024: a 25h day, so
- // "now + 24h" would land at 23:00 on the 26th and never roll over.
- val now = at("2024-10-26T12:00:00", berlin)
- val next = WidgetRolloverScheduler.nextRolloverAt(now, berlin)
- assertThat(next).isEqualTo(at("2024-10-27T00:00:05", berlin))
- assertThat(next.toLocalDateTime(berlin).date).isEqualTo(LocalDate.parse("2024-10-27"))
- }
-
- @Test
- fun `a zone that repeats midnight takes the first start of day`() {
- // Sao Paulo used to end DST by moving 00:00 back to 23:00, so the day
- // began twice. Pinned as the accepted trade: the widget runs an hour
- // ahead until the next redraw. No live zone does this since 2019.
- val saoPaulo = TimeZone.of("America/Sao_Paulo")
- val next = WidgetRolloverScheduler.nextRolloverAt(
- at("2018-02-16T12:00:00", saoPaulo), saoPaulo,
- )
- val local = next.toLocalDateTime(saoPaulo)
- assertThat(local.date).isEqualTo(LocalDate.parse("2018-02-17"))
- assertThat(local.hour).isEqualTo(0)
- }
-
- @Test
- fun `a zone where midnight does not exist rolls over at the real start of day`() {
- // Cuba starts DST at 00:00, so 11 March 2018 began at 01:00 in Havana.
- // Targeting a literal 00:00 there would arm an instant on the wrong day.
- val havana = TimeZone.of("America/Havana")
- val next = WidgetRolloverScheduler.nextRolloverAt(at("2018-03-10T12:00:00", havana), havana)
- val local = next.toLocalDateTime(havana)
- assertThat(local.date).isEqualTo(LocalDate.parse("2018-03-11"))
- assertThat(local.hour).isEqualTo(1)
- assertThat(local.minute).isEqualTo(0)
- // And it is genuinely the first instant of that date, not a guess.
- assertThat(next).isEqualTo(
- LocalDate.parse("2018-03-11").atStartOfDayIn(havana) +
- WidgetRolloverScheduler.ROLLOVER_SLACK,
- )
- }
-
- // --- timezone changes ----------------------------------------------------
-
- @Test
- fun `the same instant rolls over at different times in different zones`() {
- // TIMEZONE_CHANGED must re-arm to the new local midnight: the arithmetic
- // follows the zone, not a cached offset.
- val instant = at("2026-08-27T12:00:00", berlin)
- val tokyo = TimeZone.of("Asia/Tokyo")
- val berlinNext = WidgetRolloverScheduler.nextRolloverAt(instant, berlin)
- val tokyoNext = WidgetRolloverScheduler.nextRolloverAt(instant, tokyo)
- assertThat(tokyoNext).isNotEqualTo(berlinNext)
- assertThat(tokyoNext).isLessThan(berlinNext)
- assertThat(tokyoNext.toLocalDateTime(tokyo).hour).isEqualTo(0)
- }
-
- @Test
- fun `a half-hour offset zone still lands on its own midnight`() {
- val kathmandu = TimeZone.of("Asia/Kathmandu")
- val next = WidgetRolloverScheduler.nextRolloverAt(
- at("2026-08-27T12:00:00", kathmandu), kathmandu,
- )
- val local = next.toLocalDateTime(kathmandu)
- assertThat(local.date.toString()).isEqualTo("2026-08-28")
- assertThat(local.hour).isEqualTo(0)
- }
-
- // --- the wiring ----------------------------------------------------------
-
- @Test
- fun `the receiver actually handles the action the alarm is sent with`() {
- // The single point where the whole fix would die silently: the alarm
- // fires, the receiver drops it on the action guard, nothing redraws.
- assertThat(WidgetUpdateReceiver.HANDLED_ACTIONS)
- .contains(WidgetUpdateReceiver.ACTION_ROLLOVER)
- }
-
- @Test
- fun `a data change is handled, and re-arms only on the throttle`() {
- // PROVIDER_CHANGED fires on every calendar write — each event of a bulk
- // import, each drop of a drag — so it is not an unconditional re-arm.
- // It is not dropped either: the alarm is inexact and the system may lose
- // it, and this is the only signal that keeps arriving to notice.
- assertThat(WidgetUpdateReceiver.HANDLED_ACTIONS)
- .containsAtLeast(Intent.ACTION_PROVIDER_CHANGED, Intent.ACTION_DATE_CHANGED)
- assertThat(WidgetUpdateReceiver.REARM_ACTIONS)
- .doesNotContain(Intent.ACTION_PROVIDER_CHANGED)
- }
-
- @Test
- fun `everything that loses the alarm re-arms it`() {
- assertThat(WidgetUpdateReceiver.REARM_ACTIONS).containsAtLeast(
- WidgetUpdateReceiver.ACTION_ROLLOVER,
- Intent.ACTION_BOOT_COMPLETED,
- Intent.ACTION_MY_PACKAGE_REPLACED,
- Intent.ACTION_TIME_CHANGED,
- Intent.ACTION_TIMEZONE_CHANGED,
- Intent.ACTION_DATE_CHANGED,
- )
- }
-}
diff --git a/app/src/test/java/de/jeanlucmakiola/calendula/widget/agenda/AgendaScaleTest.kt b/app/src/test/java/de/jeanlucmakiola/calendula/widget/agenda/AgendaScaleTest.kt
index 80d64f9..15804b8 100644
--- a/app/src/test/java/de/jeanlucmakiola/calendula/widget/agenda/AgendaScaleTest.kt
+++ b/app/src/test/java/de/jeanlucmakiola/calendula/widget/agenda/AgendaScaleTest.kt
@@ -15,25 +15,12 @@ class AgendaScaleTest {
// SMALL is the default, so if this fails an agenda widget whose owner never
// touched the size setting no longer looks as it did (#51).
val m = metricsFor(WidgetSize.SMALL)
- assertThat(m.title).isEqualTo(16.sp)
- assertThat(m.dayHeader).isEqualTo(13.sp)
assertThat(m.eventTitle).isEqualTo(14.sp)
assertThat(m.eventTime).isEqualTo(12.sp)
assertThat(m.placeholder).isEqualTo(14.sp)
assertThat(m.message).isEqualTo(14.sp)
- assertThat(m.stripeH).isEqualTo(36.dp)
- assertThat(m.iconImage).isEqualTo(22.dp)
- assertThat(m.iconBox).isEqualTo(40.dp)
+ assertThat(m.contentH).isEqualTo(36.dp)
assertThat(m.rowVPad).isEqualTo(4.dp)
- assertThat(m.dayHeaderTopPad).isEqualTo(10.dp)
- }
-
- @Test
- fun `the derived text indent matches the original hardcoded inset`() {
- // TEXT_INDENT replaced a hardcoded 19dp; it must still resolve to 19dp or
- // day headers and the placeholder line stop aligning with event titles.
- assertThat(TEXT_INDENT).isEqualTo(19.dp)
- assertThat(TEXT_INDENT).isEqualTo(ROW_H_PAD + STRIPE_WIDTH + STRIPE_GAP)
}
// --- the ramp ------------------------------------------------------------
@@ -43,17 +30,17 @@ class AgendaScaleTest {
val steps = WidgetSize.entries.map(::metricsFor)
steps.zipWithNext { small, big ->
- assertThat(big.title.value).isAtLeast(small.title.value)
- assertThat(big.dayHeader.value).isAtLeast(small.dayHeader.value)
+ assertThat(big.pillWeekday.value).isAtLeast(small.pillWeekday.value)
+ assertThat(big.pillDay.value).isAtLeast(small.pillDay.value)
assertThat(big.eventTitle.value).isAtLeast(small.eventTitle.value)
assertThat(big.eventTime.value).isAtLeast(small.eventTime.value)
assertThat(big.placeholder.value).isAtLeast(small.placeholder.value)
assertThat(big.message.value).isAtLeast(small.message.value)
- assertThat(big.stripeH.value).isAtLeast(small.stripeH.value)
- assertThat(big.iconImage.value).isAtLeast(small.iconImage.value)
- assertThat(big.iconBox.value).isAtLeast(small.iconBox.value)
+ assertThat(big.contentH.value).isAtLeast(small.contentH.value)
+ assertThat(big.pillWidth.value).isAtLeast(small.pillWidth.value)
assertThat(big.rowVPad.value).isAtLeast(small.rowVPad.value)
- assertThat(big.dayHeaderTopPad.value).isAtLeast(small.dayHeaderTopPad.value)
+ assertThat(big.dayGap.value).isAtLeast(small.dayGap.value)
+ assertThat(big.monthLabel.value).isAtLeast(small.monthLabel.value)
}
}
@@ -71,20 +58,28 @@ class AgendaScaleTest {
// Guards against a future edit turning "more readable" into "absurd".
val base = metricsFor(WidgetSize.SMALL)
val top = metricsFor(WidgetSize.EXTRA_LARGE)
- assertThat(top.title.value / base.title.value).isLessThan(1.5f)
+ assertThat(top.pillDay.value / base.pillDay.value).isLessThan(1.5f)
assertThat(top.eventTitle.value / base.eventTitle.value).isLessThan(1.5f)
}
// --- font scale ----------------------------------------------------------
@Test
- fun `the stripe tracks the system font scale`() {
- // The stripe is Dp, the text beside it is sp: without this the two diverge
- // at large accessibility font settings and the stripe under-runs the row.
+ fun `the row height tracks the system font scale`() {
+ // The row height is Dp, the text in it is sp: without this the two diverge
+ // at large accessibility font settings and the text outgrows the row.
val m = metricsFor(WidgetSize.SMALL)
- assertThat(m.scaledForFont(1f).stripeH).isEqualTo(36.dp)
- assertThat(m.scaledForFont(1.3f).stripeH.value).isWithin(0.01f).of(46.8f)
- assertThat(m.scaledForFont(0.85f).stripeH.value).isWithin(0.01f).of(30.6f)
+ assertThat(m.scaledForFont(1f).contentH).isEqualTo(36.dp)
+ assertThat(m.scaledForFont(1.3f).contentH.value).isWithin(0.01f).of(46.8f)
+ assertThat(m.scaledForFont(0.85f).contentH.value).isWithin(0.01f).of(30.6f)
+ }
+
+ @Test
+ fun `the date pill widens with the system font scale`() {
+ // The pill's text is sp; a fixed-width pill clips "Wed" / "23" at large font settings.
+ val m = metricsFor(WidgetSize.SMALL)
+ assertThat(m.scaledForFont(2f).pillWidth).isEqualTo(m.pillWidth * 2)
+ assertThat(m.scaledForFont(2f).rowHeight).isEqualTo(m.contentH * 2 + m.rowVPad * 2)
}
@Test
@@ -99,7 +94,7 @@ class AgendaScaleTest {
// double-count it.
val m = metricsFor(WidgetSize.MEDIUM)
val scaled = m.scaledForFont(1.3f)
- assertThat(scaled.title).isEqualTo(m.title)
+ assertThat(scaled.pillDay).isEqualTo(m.pillDay)
assertThat(scaled.eventTitle).isEqualTo(m.eventTitle)
assertThat(scaled.rowVPad).isEqualTo(m.rowVPad)
}
diff --git a/app/src/test/java/de/jeanlucmakiola/calendula/widget/countdown/CountdownTest.kt b/app/src/test/java/de/jeanlucmakiola/calendula/widget/countdown/CountdownTest.kt
new file mode 100644
index 0000000..285e9ed
--- /dev/null
+++ b/app/src/test/java/de/jeanlucmakiola/calendula/widget/countdown/CountdownTest.kt
@@ -0,0 +1,89 @@
+package de.jeanlucmakiola.calendula.widget.countdown
+
+import com.google.common.truth.Truth.assertThat
+import de.jeanlucmakiola.calendula.domain.EventInstance
+import de.jeanlucmakiola.calendula.domain.EventResponse
+import kotlinx.datetime.TimeZone
+import org.junit.jupiter.api.Test
+import kotlin.time.Duration.Companion.days
+import kotlin.time.Duration.Companion.hours
+import kotlin.time.Duration.Companion.minutes
+import kotlin.time.Duration.Companion.seconds
+import kotlin.time.Instant
+
+class CountdownTest {
+
+ private val zone = TimeZone.UTC
+ private val now = Instant.parse("2026-10-21T12:00:00Z")
+
+ private fun event(
+ start: Instant,
+ end: Instant = start + 1.hours,
+ id: Long = 1,
+ allDay: Boolean = false,
+ response: EventResponse = EventResponse.Going,
+ ) = EventInstance(
+ instanceId = id, eventId = id, calendarId = 1, title = "e$id",
+ start = start, end = end, isAllDay = allDay, color = 0, location = null, response = response,
+ )
+
+ @Test
+ fun `labels step from days to hours to minutes to now`() {
+ assertThat(countdownLabel(event(now + 30.seconds), now, zone)).isEqualTo(CountdownLabel(false, CountdownUnit.NOW, 0))
+ assertThat(countdownLabel(event(now + 24.minutes + 10.seconds), now, zone)).isEqualTo(CountdownLabel(false, CountdownUnit.MINUTES, 25))
+ assertThat(countdownLabel(event(now + 2.hours + 40.minutes), now, zone)).isEqualTo(CountdownLabel(false, CountdownUnit.HOURS, 3))
+ assertThat(countdownLabel(event(now + 1.hours + 10.minutes), now, zone)).isEqualTo(CountdownLabel(false, CountdownUnit.HOURS, 1))
+ // Tomorrow 23:00 is 35 h away but one calendar day.
+ assertThat(countdownLabel(event(now + 35.hours), now, zone)).isEqualTo(CountdownLabel(false, CountdownUnit.DAYS, 1))
+ assertThat(countdownLabel(event(now + 12.days), now, zone)).isEqualTo(CountdownLabel(false, CountdownUnit.DAYS, 12))
+ }
+
+ @Test
+ fun `a running event counts to its end`() {
+ val running = event(start = now - 20.minutes, end = now + 10.minutes)
+ assertThat(countdownLabel(running, now, zone)).isEqualTo(CountdownLabel(true, CountdownUnit.MINUTES, 10))
+ }
+
+ @Test
+ fun `the next redraw lands where the label changes`() {
+ // "in 25 min" turns into "in 24 min" a minute later.
+ assertThat(nextLabelChange(event(now + 25.minutes), now, zone)).isEqualTo(now + 1.minutes)
+ // "in 3 h" (2 h 40 min) holds until 2 h 30 min are left.
+ assertThat(nextLabelChange(event(now + 2.hours + 40.minutes), now, zone)).isEqualTo(now + 10.minutes)
+ // "in 1 h" holds until the last hour, then counts minutes.
+ assertThat(nextLabelChange(event(now + 1.hours + 10.minutes), now, zone)).isEqualTo(now + 10.minutes)
+ // Days change at midnight.
+ assertThat(nextLabelChange(event(now + 5.days), now, zone)).isEqualTo(Instant.parse("2026-10-22T00:00:00Z"))
+ }
+
+ @Test
+ fun `next mode skips all-day, declined and finished events`() {
+ val allDay = event(now + 1.hours, id = 1, allDay = true)
+ val declined = event(now + 1.hours, id = 2, response = EventResponse.Declined)
+ val over = event(now - 2.hours, now - 1.hours, id = 3)
+ val later = event(now + 3.hours, id = 4)
+ val running = event(now - 1.hours, now + 2.hours, id = 5)
+ assertThat(pickNextEvent(listOf(allDay, declined, over, later), now)?.eventId).isEqualTo(4)
+ // A running event ending before the next one starts comes first.
+ assertThat(pickNextEvent(listOf(later, running), now)?.eventId).isEqualTo(5)
+ }
+
+ @Test
+ fun `a pinned series follows its next occurrence`() {
+ val past = event(now - 7.days)
+ val next = event(now + 7.days)
+ assertThat(pickPinnedOccurrence(listOf(past, next), now, zone)).isEqualTo(next)
+ assertThat(pickPinnedOccurrence(listOf(past), now, zone)).isNull()
+ }
+
+ @Test
+ fun `an all-day event counts in local days west of UTC`() {
+ val newYork = TimeZone.of("America/New_York")
+ // 21 Oct, 20:00 in New York; the vacation is all day on the 23rd.
+ val evening = Instant.parse("2026-10-22T00:00:00Z")
+ val vacation = event(Instant.parse("2026-10-23T00:00:00Z"), Instant.parse("2026-10-24T00:00:00Z"), allDay = true)
+ val counted = vacation.counted(newYork)
+ assertThat(counted.start).isEqualTo(Instant.parse("2026-10-23T04:00:00Z"))
+ assertThat(countdownLabel(counted, evening, newYork)).isEqualTo(CountdownLabel(false, CountdownUnit.DAYS, 2))
+ }
+}
diff --git a/app/src/test/java/de/jeanlucmakiola/calendula/widget/month/MonthGridTest.kt b/app/src/test/java/de/jeanlucmakiola/calendula/widget/month/MonthGridTest.kt
new file mode 100644
index 0000000..fa69afc
--- /dev/null
+++ b/app/src/test/java/de/jeanlucmakiola/calendula/widget/month/MonthGridTest.kt
@@ -0,0 +1,110 @@
+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 androidx.compose.ui.unit.dp
+import org.junit.jupiter.api.Test
+
+/** How the month widget seats a week's events into lanes (#372). */
+class MonthGridTest {
+
+ private val zone = TimeZone.UTC
+ private val jul26 = YearMonth(2026, Month.JULY)
+
+ /** Monday Jul 6 to Sunday Jul 12. */
+ private val monday = LocalDate(2026, 7, 6)
+
+ private fun weeks(events: List): List =
+ layoutMonthWeeks(jul26, DayOfWeek.MONDAY, events, zone)
+
+ /** July 2026 with [perDay] timed events on every day of the grid. */
+ private fun busyWeeks(perDay: Int): List {
+ 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") }
+ },
+ )
+ }
+
+ @Test
+ fun `a week that fits keeps every lane`() {
+ val layouts = fitWeeks(busyWeeks(perDay = 2), lanes = 3)
+ assertThat(layouts.map { it.laneCap }.distinct()).containsExactly(3)
+ assertThat(layouts.all { it.overflow.isEmpty() }).isTrue()
+ }
+
+ @Test
+ fun `a week that doesn't fit gives its last lane to the overflow`() {
+ val layouts = fitWeeks(busyWeeks(perDay = 5), lanes = 3)
+ assertThat(layouts.map { it.laneCap }.distinct()).containsExactly(2)
+ assertThat(layouts.all { it.overflow.isNotEmpty() }).isTrue()
+ }
+
+ @Test
+ fun `a taller week fits more lanes`() {
+ assertThat(lanesFitting(19.dp)).isEqualTo(0)
+ assertThat(lanesFitting(50.dp)).isEqualTo(2)
+ assertThat(lanesFitting(100.dp)).isEqualTo(5)
+ }
+
+ @Test
+ fun `lanes stop at the cap however tall the week`() {
+ assertThat(lanesFitting(1000.dp)).isEqualTo(MAX_LANES)
+ }
+
+ @Test
+ fun `a week with no room for a lane draws no overflow row either`() {
+ val layouts = fitWeeks(busyWeeks(perDay = 5), lanes = 0)
+ assertThat(layouts.none { it.showsOverflow }).isTrue()
+ assertThat(layouts.all { it.lanes.isEmpty() }).isTrue()
+ }
+
+ @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], laneCap = 3)
+ 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()
+ }
+}
diff --git a/app/src/test/java/de/jeanlucmakiola/calendula/widget/split/MonthGridDaysTest.kt b/app/src/test/java/de/jeanlucmakiola/calendula/widget/split/MonthGridDaysTest.kt
new file mode 100644
index 0000000..40578e6
--- /dev/null
+++ b/app/src/test/java/de/jeanlucmakiola/calendula/widget/split/MonthGridDaysTest.kt
@@ -0,0 +1,24 @@
+package de.jeanlucmakiola.calendula.widget.split
+
+import com.google.common.truth.Truth.assertThat
+import kotlinx.datetime.DayOfWeek
+import kotlinx.datetime.LocalDate
+import org.junit.jupiter.api.Test
+
+class MonthGridDaysTest {
+
+ @Test
+ fun `whole weeks from the week start cover the month and no more`() {
+ // October 2026 starts on a Thursday and has 31 days.
+ val october = 2026 * 12 + 9
+ val monday = monthGridDays(october, DayOfWeek.MONDAY)
+ assertThat(monday.first()).isEqualTo(LocalDate(2026, 9, 28))
+ assertThat(monday.last()).isEqualTo(LocalDate(2026, 11, 1))
+ assertThat(monday).hasSize(35)
+
+ // February 2027 starts on a Monday and fills exactly four weeks.
+ val february = 2027 * 12 + 1
+ assertThat(monthGridDays(february, DayOfWeek.MONDAY)).hasSize(28)
+ assertThat(monthGridDays(february, DayOfWeek.SUNDAY)).hasSize(35)
+ }
+}
diff --git a/app/src/test/java/de/jeanlucmakiola/calendula/widget/today/TodayRowsTest.kt b/app/src/test/java/de/jeanlucmakiola/calendula/widget/today/TodayRowsTest.kt
new file mode 100644
index 0000000..a0cb36c
--- /dev/null
+++ b/app/src/test/java/de/jeanlucmakiola/calendula/widget/today/TodayRowsTest.kt
@@ -0,0 +1,57 @@
+package de.jeanlucmakiola.calendula.widget.today
+
+import com.google.common.truth.Truth.assertThat
+import de.jeanlucmakiola.calendula.data.prefs.PastEventDisplay
+import de.jeanlucmakiola.calendula.domain.EventInstance
+import de.jeanlucmakiola.calendula.ui.agenda.AgendaDay
+import de.jeanlucmakiola.calendula.ui.agenda.AgendaRange
+import de.jeanlucmakiola.calendula.widget.AgendaWidgetOptions
+import de.jeanlucmakiola.calendula.widget.DaysWidgetData
+import de.jeanlucmakiola.calendula.widget.WidgetSize
+import kotlinx.datetime.DayOfWeek
+import kotlinx.datetime.LocalDate
+import org.junit.jupiter.api.Test
+import kotlin.time.Duration.Companion.hours
+import kotlin.time.Instant
+
+class TodayRowsTest {
+
+ private val now = Instant.parse("2026-10-21T12:00:00Z")
+ private val today = LocalDate(2026, 10, 21)
+ private val tomorrow = LocalDate(2026, 10, 22)
+
+ private fun event(id: Long, start: Instant, end: Instant) = EventInstance(
+ instanceId = id, eventId = id, calendarId = 1, title = "e$id",
+ start = start, end = end, isAllDay = false, color = 0, location = null,
+ )
+
+ private fun data(past: PastEventDisplay, vararg days: AgendaDay) = DaysWidgetData.Ready(
+ today = today,
+ firstDay = today,
+ lastDay = tomorrow,
+ days = days.toList(),
+ weekStart = DayOfWeek.MONDAY,
+ is24Hour = true,
+ soften = true,
+ options = AgendaWidgetOptions(AgendaRange.Day, WidgetSize.SMALL, true, past),
+ now = now,
+ )
+
+ private val ended = event(1, now - 3.hours, now - 1.hours)
+ private val later = event(2, now + 1.hours, now + 2.hours)
+ private val next = event(3, now + 22.hours, now + 23.hours)
+
+ @Test
+ fun `an event still to come lists today, finished ones as the past setting says`() {
+ val days = arrayOf(AgendaDay(today, listOf(ended, later)), AgendaDay(tomorrow, listOf(next)))
+ assertThat(todayRows(data(PastEventDisplay.DIM, *days))).isEqualTo(TodayRows.Today(listOf(ended, later)))
+ assertThat(todayRows(data(PastEventDisplay.HIDE, *days))).isEqualTo(TodayRows.Today(listOf(later)))
+ }
+
+ @Test
+ fun `once today is over it looks ahead to tomorrow`() {
+ val over = data(PastEventDisplay.SHOW, AgendaDay(today, listOf(ended)), AgendaDay(tomorrow, listOf(next)))
+ assertThat(todayRows(over)).isEqualTo(TodayRows.NothingLeft(listOf(next)))
+ assertThat(todayRows(data(PastEventDisplay.SHOW))).isEqualTo(TodayRows.NothingLeft(emptyList()))
+ }
+}
diff --git a/app/src/test/java/de/jeanlucmakiola/calendula/widget/upcoming/UpcomingEventsTest.kt b/app/src/test/java/de/jeanlucmakiola/calendula/widget/upcoming/UpcomingEventsTest.kt
new file mode 100644
index 0000000..69dd7d2
--- /dev/null
+++ b/app/src/test/java/de/jeanlucmakiola/calendula/widget/upcoming/UpcomingEventsTest.kt
@@ -0,0 +1,51 @@
+package de.jeanlucmakiola.calendula.widget.upcoming
+
+import com.google.common.truth.Truth.assertThat
+import de.jeanlucmakiola.calendula.data.prefs.PastEventDisplay
+import de.jeanlucmakiola.calendula.domain.EventInstance
+import de.jeanlucmakiola.calendula.ui.agenda.AgendaDay
+import de.jeanlucmakiola.calendula.ui.agenda.AgendaRange
+import de.jeanlucmakiola.calendula.widget.AgendaWidgetOptions
+import de.jeanlucmakiola.calendula.widget.DaysWidgetData
+import de.jeanlucmakiola.calendula.widget.WidgetSize
+import kotlinx.datetime.DayOfWeek
+import kotlinx.datetime.LocalDate
+import org.junit.jupiter.api.Test
+import kotlin.time.Duration.Companion.hours
+import kotlin.time.Instant
+
+class UpcomingEventsTest {
+
+ private val now = Instant.parse("2026-10-21T12:00:00Z")
+
+ private fun event(id: Long, start: Instant, end: Instant) = EventInstance(
+ instanceId = id, eventId = id, calendarId = 1, title = "e$id",
+ start = start, end = end, isAllDay = false, color = 0, location = null,
+ )
+
+ private fun data(vararg days: AgendaDay) = DaysWidgetData.Ready(
+ today = LocalDate(2026, 10, 21),
+ firstDay = LocalDate(2026, 10, 21),
+ lastDay = LocalDate(2026, 10, 27),
+ days = days.toList(),
+ weekStart = DayOfWeek.MONDAY,
+ is24Hour = true,
+ soften = true,
+ options = AgendaWidgetOptions(AgendaRange.Week, WidgetSize.SMALL, true, PastEventDisplay.SHOW),
+ now = now,
+ )
+
+ @Test
+ fun `ended events drop out and a multi-day one lists once, on its first day`() {
+ val ended = event(1, now - 3.hours, now - 1.hours)
+ val running = event(2, now - 1.hours, now + 30.hours)
+ val later = event(3, now + 2.hours, now + 3.hours)
+ val today = AgendaDay(LocalDate(2026, 10, 21), listOf(ended, running, later))
+ val tomorrow = AgendaDay(LocalDate(2026, 10, 22), listOf(running))
+
+ val upcoming = upcomingEvents(data(today, tomorrow))
+
+ assertThat(upcoming.map { it.second.instanceId }).containsExactly(2L, 3L).inOrder()
+ assertThat(upcoming.first().first).isEqualTo(LocalDate(2026, 10, 21))
+ }
+}
diff --git a/floret-kit b/floret-kit
index 9f90027..6b5ee40 160000
--- a/floret-kit
+++ b/floret-kit
@@ -1 +1 @@
-Subproject commit 9f90027dcd9f1f7a0511bea462ecdf297387b912
+Subproject commit 6b5ee409b3068f2e7a78be7dea80104fed502cd8