Compare commits

...
Author SHA1 Message Date
makiolaj 610409012f Month widget: day cells and weekday pills, shaded outside days, new-event button (#71) 2026-10-23 17:59:34 +02:00
makiolaj 7e45656479 Pin floret-kit past its lint fix 2026-10-23 15:48:44 +02:00
makiolaj 94b04c1365 Month widget grid: palette-following colours, day ripples, bold today, strike via span, lane cap (#372) 2026-10-23 15:45:54 +02:00
makiolaj d1557825c6 Month widget: draw the weeks as plain RemoteViews, lanes from the widget's height (#372)
Glance's 499 view ids forced busy months down to one or two lanes. The week rows are now one embedded RemoteViews tree, so every event that fits the height gets its bar and only the rest folds into +N. The on-device test now checks the payload instead of the view count.
2026-10-23 15:16:28 +02:00
makiolaj d43a741c93 Agenda widget: format the month label outside the composable (#71) 2026-10-23 15:16:28 +02:00
makiolaj 6da0648b77 Widget redesign: agenda date pills and tinted day cards, month labels, pill headers, true-size config preview (#71, #90)
Drops the agenda refresh button (redraws are automatic since #420). Caps the config preview at Glance's 10-row Column limit, which made it fail with busy agendas (#292).
2026-10-23 14:51:29 +02:00
makiolaj 3b48987ab7 Merge #292 widget config into the widget refactor 2026-10-23 11:12:24 +02:00
makiolaj 04a677dc10 Per-widget config screen: background, theme and agenda options (#292) 2026-10-23 11:12:18 +02:00
makiolaj 46ca2db63a Month widget: count Glance's real view ids per Android version, test the budget on device (#372) 2026-10-21 18:44:14 +02:00
makiolaj a87ebc33b6 Month widget: tap a day anywhere opens it, fit busy months into Glance's view limit (#386, #372) 2026-10-21 18:18:01 +02:00
makiolaj 3471447ab6 Pin floret-kit to main (glance refresh helpers, #18) 2026-10-21 17:57:04 +02:00
makiolaj b206e0e6a5 Bump floret-kit onto current main 2026-10-21 17:48:24 +02:00
makiolaj 35f5dc1666 Fix widget onUpdate crash, skip provider echoes, per-widget redraws (#420, #229) 2026-10-21 17:36:30 +02:00
makiolaj 225901ceee Share widget loads across sizes, redraw on own changes only (#420, #229) 2026-10-21 17:02:43 +02:00
makiolaj 77e103c66c Move widgets onto floret-kit glance, reload on every redraw (#420, #229) 2026-10-21 15:26:05 +02:00
62 changed files with 2937 additions and 1478 deletions
+1
View File
@@ -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)
@@ -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
}
}
+18 -6
View File
@@ -318,6 +318,16 @@
android:exported="false" />
<!-- Home-screen widgets (Glance). Exported: the launcher/host binds them. -->
<activity
android:name=".widget.config.WidgetConfigActivity"
android:exported="true"
android:excludeFromRecents="true"
android:taskAffinity=""
android:theme="@style/Theme.Calendula.WidgetConfig">
<intent-filter>
<action android:name="android.appwidget.action.APPWIDGET_CONFIGURE" />
</intent-filter>
</activity>
<receiver
android:name=".widget.agenda.AgendaWidgetReceiver"
android:label="@string/widget_agenda_label"
@@ -343,12 +353,13 @@
</receiver>
<!-- Keeps both widgets fresh: the calendar provider broadcasts
PROVIDER_CHANGED on any data change (our writes and external sync).
The day boundary arrives as the app's own ROLLOVER alarm (#228), by
explicit PendingIntent, so it needs no filter here; DATE_CHANGED is
a free extra only, since Android 8+ withholds it from manifest
receivers. The four below re-arm that alarm: TIME_SET /
TIMEZONE_CHANGED move the boundary, boot / package-replace wipe it.
PROVIDER_CHANGED on any data change (external sync; the app's own
writes redraw directly). The day boundary arrives as the app's own
ROLLOVER alarm (#228), by explicit PendingIntent, so it needs no
filter here; DATE_CHANGED is a free extra only, since Android 8+
withholds it from manifest receivers. TIME_SET / TIMEZONE_CHANGED
move the boundary, boot / package-replace wipe the alarm, and
LOCALE_CHANGED redraws the labels in the new language.
Exported: the system broadcasts arrive from outside the app. -->
<receiver
android:name=".widget.WidgetUpdateReceiver"
@@ -365,6 +376,7 @@
<action android:name="android.intent.action.TIMEZONE_CHANGED" />
<action android:name="android.intent.action.BOOT_COMPLETED" />
<action android:name="android.intent.action.MY_PACKAGE_REPLACED" />
<action android:name="android.intent.action.LOCALE_CHANGED" />
</intent-filter>
</receiver>
@@ -10,7 +10,8 @@ import de.jeanlucmakiola.calendula.data.contacts.SpecialDatesScheduler
import de.jeanlucmakiola.calendula.data.contacts.SpecialDatesSyncWorker
import de.jeanlucmakiola.calendula.data.reminders.ReminderMaintenanceScheduler
import de.jeanlucmakiola.calendula.data.reminders.ReminderMaintenanceWorker
import de.jeanlucmakiola.calendula.widget.WidgetRolloverScheduler
import de.jeanlucmakiola.calendula.widget.syncWidgetRollover
import de.jeanlucmakiola.calendula.widget.widgetEntryPoint
import de.jeanlucmakiola.floret.crash.CrashConfig
import de.jeanlucmakiola.floret.crash.CrashReporter
import kotlinx.coroutines.CoroutineScope
@@ -43,6 +44,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()
@@ -58,7 +61,7 @@ class CalendulaApp : Application() {
*/
private fun reconcileWidgetRollover() {
CoroutineScope(SupervisorJob() + Dispatchers.Default).launch {
WidgetRolloverScheduler.sync(this@CalendulaApp)
syncWidgetRollover(this@CalendulaApp)
}
}
@@ -18,6 +18,10 @@ interface CalendarRepository {
/** Re-query every open [calendars] / [instances] flow. */
fun refresh()
/** Emits on the app's own writes and visibility changes; not on provider notifications or [refresh]. */
val localChanges: Flow<Unit>
suspend fun eventDetail(eventId: Long): EventDetail
/**
@@ -78,15 +78,28 @@ class CalendarRepositoryImpl @Inject constructor(
ticks.tryEmit(Unit)
}
private val writes = MutableSharedFlow<Unit>(
replay = 0,
extraBufferCapacity = 1,
onBufferOverflow = BufferOverflow.DROP_OLDEST,
)
/** Run a provider write on [io], then re-query whether or not it threw. */
private suspend fun <T> write(block: suspend () -> T): T = withContext(io) {
try {
block()
} finally {
refresh()
writes.tryEmit(Unit)
}
}
override val localChanges: Flow<Unit> = merge(
writes,
prefs.pendingDisabledCalendarIds.distinctUntilChanged().drop(1).map {},
prefs.hiddenCalendarIds.distinctUntilChanged().drop(1).map {},
)
/**
* Re-query signal for everything filtered by visibility: the provider's own
* notifications, plus every change to the pending switch-off set.
@@ -154,8 +154,6 @@ class SettingsPrefs @Inject constructor(
val dragToReschedule: Flow<Boolean> = prefs.flow(SettingsKeys.dragToReschedule)
val tapOpensEditor: Flow<Boolean> = prefs.flow(SettingsKeys.tapOpensEditor)
val agendaScreenRange: Flow<AgendaRange> = prefs.flow(SettingsKeys.agendaScreenRange)
val agendaWidgetRange: Flow<AgendaRange> = prefs.flow(SettingsKeys.agendaWidgetRange)
val widgetSize: Flow<WidgetSize> = prefs.flow(SettingsKeys.widgetSize)
val agendaShowRangeBar: Flow<Boolean> = prefs.flow(SettingsKeys.agendaShowRangeBar)
val agendaShowToday: Flow<Boolean> = prefs.flow(SettingsKeys.agendaShowToday)
val defaultView: Flow<CalendarView> = prefs.flow(SettingsKeys.defaultView)
@@ -231,8 +229,6 @@ class SettingsPrefs @Inject constructor(
suspend fun setDragToReschedule(enabled: Boolean) = prefs.set(SettingsKeys.dragToReschedule, enabled)
suspend fun setTapOpensEditor(enabled: Boolean) = prefs.set(SettingsKeys.tapOpensEditor, enabled)
suspend fun setAgendaScreenRange(range: AgendaRange) = prefs.set(SettingsKeys.agendaScreenRange, range)
suspend fun setAgendaWidgetRange(range: AgendaRange) = prefs.set(SettingsKeys.agendaWidgetRange, range)
suspend fun setWidgetSize(size: WidgetSize) = prefs.set(SettingsKeys.widgetSize, size)
suspend fun setAgendaShowRangeBar(enabled: Boolean) = prefs.set(SettingsKeys.agendaShowRangeBar, enabled)
suspend fun setAgendaShowToday(enabled: Boolean) = prefs.set(SettingsKeys.agendaShowToday, enabled)
suspend fun setDefaultView(view: CalendarView) = prefs.set(SettingsKeys.defaultView, view)
@@ -497,13 +493,16 @@ object SettingsKeys {
/** How far ahead the in-app Agenda screen shows events (v2.11). */
val agendaScreenRange: Pref<AgendaRange> = agendaRangePref("agenda_screen_range")
/** How far ahead the agenda **widget** shows events (v2.11). */
/**
* How far ahead the agenda **widget** shows events (v2.11). Read-only since
* widgets keep their own options (#292): the default for a widget that has
* none yet.
*/
val agendaWidgetRange: Pref<AgendaRange> = agendaRangePref("agenda_widget_range")
/**
* The size step the agenda widget draws its text at (#51). [WidgetSize.SMALL]
* reproduces its original metrics. The month widget takes no size setting —
* it divides the width it is given by seven (#103).
* reproduces its original metrics. Read-only like [agendaWidgetRange] (#292).
*/
val widgetSize: Pref<WidgetSize> = enumPref("widget_size", WidgetSize.SMALL)
@@ -511,8 +510,8 @@ object SettingsKeys {
val agendaShowRangeBar: Pref<Boolean> = booleanPref("agenda_show_range_bar", true)
/**
* Whether the agenda (screen and widget) always anchors today at the top with
* a "nothing left today" placeholder (issue #35).
* Whether the agenda screen always anchors today at the top with a "nothing
* left today" placeholder (issue #35), and the default for a widget (#292).
*/
val agendaShowToday: Pref<Boolean> = booleanPref("agenda_show_today", true)
@@ -155,7 +155,7 @@ fun SettingsScreen(
enter = slideInHorizontally(slideSpec) { it } + fadeIn(),
exit = overlayExit,
) {
WidgetsScreen(state = state, viewModel = viewModel, onBack = { section = null }, visible = section == SettingsSection.Widgets)
WidgetsScreen(onBack = { section = null }, visible = section == SettingsSection.Widgets)
}
AnimatedVisibility(
visible = section == SettingsSection.Licences,
@@ -16,7 +16,6 @@ import de.jeanlucmakiola.calendula.ui.common.QuickSwitchConfig
import de.jeanlucmakiola.calendula.ui.common.TimelineHours
import de.jeanlucmakiola.calendula.ui.common.TimelineScale
import de.jeanlucmakiola.calendula.ui.month.MonthViewStyle
import de.jeanlucmakiola.calendula.widget.WidgetSize
/**
* Settings screen state (M4). Persisted preferences are instant to read, so
@@ -51,14 +50,10 @@ data class SettingsUiState(
val tapOpensEditor: Boolean = false,
/** How far ahead the in-app Agenda screen shows events (v2.11). */
val agendaScreenRange: AgendaRange = AgendaRange.Month,
/** How far ahead the agenda widget shows events (v2.11). */
val agendaWidgetRange: AgendaRange = AgendaRange.Month,
/** Whether the agenda (screen + widget) always anchors today at the top (#35). */
/** Whether the agenda screen always anchors today at the top (#35); widgets have their own (#292). */
val agendaShowToday: Boolean = true,
/** Whether the agenda shows its top range bar — header + switcher (v2.11). */
val agendaShowRangeBar: Boolean = true,
/** The size step the agenda widget draws its text at (#51). */
val widgetSize: WidgetSize = WidgetSize.SMALL,
/** The calendar view the app opens on, and the home of the view back stack (M1). */
val defaultView: CalendarView = CalendarView.Week,
/** Which views the top-bar quick-switch button cycles through, and their order (#24). */
@@ -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<Unit>(extraBufferCapacity = 1)
val fontImportFailed: SharedFlow<Unit> = _fontImportFailed
// Serialises widget redraws: concurrent updateAll calls coalesce around a
// stale read and can leave the widget on an intermediate value.
private val widgetRefreshMutex = Mutex()
/** Contact special-dates sub-page (issue #15). */
val specialDatesState: StateFlow<SpecialDatesUiState> = 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) }
@@ -10,7 +10,9 @@ import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.height
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.produceState
import androidx.lifecycle.compose.LifecycleResumeEffect
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
@@ -18,56 +20,47 @@ import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import de.jeanlucmakiola.calendula.R
import de.jeanlucmakiola.calendula.data.prefs.resolveFirstDay
import de.jeanlucmakiola.calendula.qs.NewEventTileService
import de.jeanlucmakiola.calendula.ui.common.AgendaRangePicker
import de.jeanlucmakiola.calendula.ui.common.agendaRangeLabel
import de.jeanlucmakiola.calendula.widget.WidgetSize
import de.jeanlucmakiola.calendula.widget.agenda.metricsFor
import de.jeanlucmakiola.floret.components.CollapsingScaffold
import de.jeanlucmakiola.floret.components.GroupedHint
import de.jeanlucmakiola.floret.components.GroupedRow
import de.jeanlucmakiola.floret.components.OptionPicker
import de.jeanlucmakiola.floret.components.Position
import de.jeanlucmakiola.floret.locale.currentLocale
import kotlin.math.roundToInt
import de.jeanlucmakiola.floret.components.GroupedSectionHeader
import de.jeanlucmakiola.floret.components.positionOf
import de.jeanlucmakiola.calendula.widget.config.PlacedWidget
import de.jeanlucmakiola.calendula.widget.config.WidgetConfigActivity
import de.jeanlucmakiola.calendula.widget.config.WidgetKind
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import de.jeanlucmakiola.calendula.widget.config.backgroundLabel
import de.jeanlucmakiola.calendula.widget.config.label
import de.jeanlucmakiola.calendula.widget.config.placedWidgets
import de.jeanlucmakiola.calendula.widget.config.themeLabel
/**
* Widgets & tiles (#69): the home-screen widgets' settings and the Quick
* Settings tile shortcut. The agenda widget keeps its own range and text size,
* separate from the Agenda screen's range in [ViewsScreen].
* Widgets & tiles (#69): the placed home-screen widgets, each opening its own
* config screen (#292), and the Quick Settings tile shortcut.
*/
@Composable
internal fun WidgetsScreen(
state: SettingsUiState,
viewModel: SettingsViewModel,
onBack: () -> Unit,
visible: Boolean = true,
) {
var showAgendaWidgetRange by remember { mutableStateOf(false) }
var showWidgetSize by remember { mutableStateOf(false) }
CollapsingScaffold(
title = stringResource(R.string.settings_section_widgets),
onBack = onBack,
predictiveBack = true,
visible = visible,
) {
GroupedHint(stringResource(R.string.settings_widgets_hint))
Spacer(Modifier.height(8.dp))
GroupedRow(
title = stringResource(R.string.settings_agenda_widget_range),
summary = agendaRangeLabel(state.agendaWidgetRange),
position = Position.Top,
onClick = { showAgendaWidgetRange = true },
)
GroupedRow(
title = stringResource(R.string.settings_widget_size),
summary = widgetSizeLabel(state.widgetSize),
position = Position.Bottom,
onClick = { showWidgetSize = true },
GroupedHint(
stringResource(
// The launcher's "Edit widget" action only exists from Android 12.
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) R.string.settings_widgets_hint
else R.string.settings_widgets_hint_legacy,
),
)
GroupedSectionHeader(stringResource(R.string.settings_widgets_placed))
PlacedWidgetRows()
// The add-tile prompt is API 33+; below that the tile is still
// addable from the QS editor, so the row just doesn't appear.
@@ -76,30 +69,44 @@ internal fun WidgetsScreen(
QuickSettingsTileRow()
}
}
}
if (showAgendaWidgetRange) {
AgendaRangePicker(
title = stringResource(R.string.settings_agenda_widget_range),
description = stringResource(R.string.settings_agenda_widget_range_hint),
selected = state.agendaWidgetRange,
weekStart = state.weekStart.resolveFirstDay(currentLocale()),
onSelect = viewModel::setAgendaWidgetRange,
onDismiss = { showAgendaWidgetRange = false },
)
@Composable
private fun PlacedWidgetRows() {
val context = LocalContext.current
// Read on every resume, the first included, so a widget added, removed or
// edited meanwhile shows up.
var refresh by remember { mutableIntStateOf(0) }
LifecycleResumeEffect(Unit) {
refresh++
onPauseOrDispose {}
}
if (showWidgetSize) {
OptionPicker(
title = stringResource(R.string.settings_widget_size),
header = { GroupedHint(stringResource(R.string.settings_widget_size_hint), verticalPadding = 8.dp) },
predictiveBack = true,
options = WidgetSize.entries,
selected = state.widgetSize,
label = { widgetSizeLabel(it) },
// No live preview: the widget is Glance/RemoteViews, so a Compose
// mock-up would be a second implementation free to drift.
summary = { widgetSizeSummary(it) },
onSelect = viewModel::setWidgetSize,
onDismiss = { showWidgetSize = false },
val widgets by produceState<List<PlacedWidget>?>(initialValue = null, refresh) {
if (refresh > 0) value = withContext(Dispatchers.IO) { context.placedWidgets() }
}
val placed = widgets ?: return
if (placed.isEmpty()) {
GroupedRow(
title = stringResource(R.string.settings_widgets_none),
position = Position.Alone,
dimmed = true,
)
return
}
val kindCounts = placed.groupingBy { it.kind }.eachCount()
val kindSeen = mutableMapOf<WidgetKind, Int>()
placed.forEachIndexed { index, widget ->
val number = kindSeen.merge(widget.kind, 1, Int::plus)!!
val label = stringResource(widget.kind.label)
GroupedRow(
title = if (kindCounts.getValue(widget.kind) > 1) stringResource(R.string.widget_numbered, label, number) else label,
summary = stringResource(
R.string.widget_appearance_summary,
backgroundLabel(widget.appearance.background),
themeLabel(widget.appearance.theme),
),
position = positionOf(index, placed.size),
onClick = { context.startActivity(WidgetConfigActivity.intent(context, widget.appWidgetId)) },
)
}
}
@@ -131,24 +138,3 @@ private fun requestAddQsTile(context: Context) {
context.mainExecutor,
) { /* result code unused — the system surfaces its own feedback */ }
}
/**
* What a size step does, as a percentage of the smallest one's event text.
* Derived from the widget's own [metricsFor] table rather than hardcoded.
*/
@Composable
private fun widgetSizeSummary(size: WidgetSize): String {
val base = metricsFor(WidgetSize.SMALL).eventTitle.value
val percent = (metricsFor(size).eventTitle.value / base * 100f).roundToInt()
return stringResource(R.string.settings_widget_size_summary, percent)
}
@Composable
private fun widgetSizeLabel(size: WidgetSize): String = stringResource(
when (size) {
WidgetSize.SMALL -> R.string.settings_widget_size_small
WidgetSize.MEDIUM -> R.string.settings_widget_size_medium
WidgetSize.LARGE -> R.string.settings_widget_size_large
WidgetSize.EXTRA_LARGE -> R.string.settings_widget_size_extra_large
},
)
@@ -0,0 +1,88 @@
package de.jeanlucmakiola.calendula.widget
import android.content.Context
import androidx.datastore.preferences.core.MutablePreferences
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.booleanPreferencesKey
import androidx.datastore.preferences.core.stringPreferencesKey
import androidx.glance.GlanceId
import androidx.glance.appwidget.state.getAppWidgetState
import androidx.glance.state.PreferencesGlanceStateDefinition
import de.jeanlucmakiola.calendula.data.prefs.PastEventDisplay
import de.jeanlucmakiola.calendula.ui.agenda.AgendaRange
import de.jeanlucmakiola.calendula.ui.agenda.parseAgendaRange
import de.jeanlucmakiola.calendula.ui.agenda.storageValue
import de.jeanlucmakiola.floret.glance.WidgetBackground
import de.jeanlucmakiola.floret.glance.WidgetThemeMode
import de.jeanlucmakiola.floret.prefs.ThemeMode
import de.jeanlucmakiola.floret.prefs.toEnum
/** A widget's own light/dark choice (#292); [FOLLOW_APP] tracks the app's theme setting. */
enum class WidgetTheme { FOLLOW_APP, LIGHT, DARK }
/** How a widget looks, set per placed widget in its config screen (#292). */
data class WidgetAppearance(
val background: WidgetBackground = WidgetBackground.TINTED,
val theme: WidgetTheme = WidgetTheme.FOLLOW_APP,
)
/** What the agenda widget lists, per placed widget. */
data class AgendaWidgetOptions(
val range: AgendaRange,
val textSize: WidgetSize,
val showToday: Boolean,
val pastDisplay: PastEventDisplay,
)
internal fun WidgetTheme.resolve(app: ThemeMode): WidgetThemeMode = when (this) {
WidgetTheme.LIGHT -> WidgetThemeMode.LIGHT
WidgetTheme.DARK -> WidgetThemeMode.DARK
WidgetTheme.FOLLOW_APP -> when (app) {
ThemeMode.SYSTEM -> WidgetThemeMode.SYSTEM
ThemeMode.LIGHT -> WidgetThemeMode.LIGHT
ThemeMode.DARK, ThemeMode.BLACK -> WidgetThemeMode.DARK
}
}
private val BackgroundKey = stringPreferencesKey("cfg_background")
private val ThemeKey = stringPreferencesKey("cfg_theme")
private val AgendaRangeKey = stringPreferencesKey("cfg_agenda_range")
private val TextSizeKey = stringPreferencesKey("cfg_text_size")
private val ShowTodayKey = booleanPreferencesKey("cfg_show_today")
private val PastDisplayKey = stringPreferencesKey("cfg_past_display")
internal val Preferences.widgetAppearance: WidgetAppearance
get() = WidgetAppearance(
background = this[BackgroundKey].toEnum(WidgetBackground.TINTED),
theme = this[ThemeKey].toEnum(WidgetTheme.FOLLOW_APP),
)
/**
* This widget's agenda options. A widget placed before per-widget options
* existed has none stored, and keeps following the app-wide values in
* [defaults] until it is configured.
*/
internal fun Preferences.agendaWidgetOptions(defaults: AgendaWidgetOptions) = AgendaWidgetOptions(
range = parseAgendaRange(this[AgendaRangeKey], defaults.range),
textSize = this[TextSizeKey].toEnum(defaults.textSize),
showToday = this[ShowTodayKey] ?: defaults.showToday,
pastDisplay = this[PastDisplayKey].toEnum(defaults.pastDisplay),
)
/** Whether this widget has agenda options of its own, rather than following the app's. */
internal val Preferences.hasAgendaOptions: Boolean get() = this[AgendaRangeKey] != null
internal fun MutablePreferences.putAppearance(appearance: WidgetAppearance) {
this[BackgroundKey] = appearance.background.name
this[ThemeKey] = appearance.theme.name
}
internal fun MutablePreferences.putAgendaOptions(options: AgendaWidgetOptions) {
this[AgendaRangeKey] = options.range.storageValue()
this[TextSizeKey] = options.textSize.name
this[ShowTodayKey] = options.showToday
this[PastDisplayKey] = options.pastDisplay.name
}
internal suspend fun Context.widgetState(id: GlanceId): Preferences =
getAppWidgetState(this, PreferencesGlanceStateDefinition, id)
@@ -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,7 +45,54 @@ 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<out T>(val content: T, val appTheme: ThemeMode)
internal val SettingsPrefs.widgetSettings: Flow<WidgetSettings>
get() = data.map {
WidgetSettings(
soften = it[SettingsKeys.softenCalendarColors],
weekStart = it[SettingsKeys.weekStart],
timeFormat = it[SettingsKeys.timeFormat],
themeMode = it[SettingsKeys.themeMode],
pastDisplay = it[SettingsKeys.pastEventDisplay],
agendaRange = it[SettingsKeys.agendaWidgetRange],
widgetSize = it[SettingsKeys.widgetSize],
showToday = it[SettingsKeys.agendaShowToday],
)
}.distinctUntilChanged()
internal fun systemZone(): TimeZone = TimeZone.currentSystemDefault()
@@ -51,112 +109,72 @@ 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<AgendaDay>,
/** 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<EventInstance>,
/** 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<AgendaWidgetData> {
val settings = widgetEntryPoint().settingsPrefs().widgetSettings.first()
val options = widgetState(id).agendaWidgetOptions(settings.agendaDefaults)
return Themed(loadAgendaWidgetData(settings, options), settings.themeMode)
}
/**
* One-shot read of the upcoming agenda for the widget. Reuses the app's
* [agendaRange] window and [groupAgendaDays] grouping, and the repository's
* [first]-emitted snapshot already has hidden calendars filtered out.
*
* 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,
): 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()
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 +183,50 @@ 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<MonthWidgetSource.Ready>)
private val entries = ConcurrentHashMap<GlanceId, Entry>()
fun get(id: GlanceId, center: Int, stamp: Long): Themed<MonthWidgetSource>? =
entries[id]?.takeIf { it.center == center && it.stamp == stamp && it.source.content.today == today(systemZone()) }
?.source
fun put(id: GlanceId, center: Int, stamp: Long, source: Themed<MonthWidgetSource>) {
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<MonthWidgetSource> =
MonthSourceCache.get(id, centerIndex, stamp)
?: loadThemedMonthSource(centerIndex).also { MonthSourceCache.put(id, centerIndex, stamp, it) }
/** Read backing the month widget's grid for the window around [centerIndex], with the app theme. */
internal suspend fun Context.loadThemedMonthSource(centerIndex: Int): Themed<MonthWidgetSource> {
val settings = widgetEntryPoint().settingsPrefs().widgetSettings.first()
return Themed(queryMonthSource(centerIndex, settings), settings.themeMode)
}
private suspend fun Context.queryMonthSource(centerIndex: Int, settings: WidgetSettings): MonthWidgetSource {
if (!hasCalendarPermission()) return MonthWidgetSource.NeedsPermission
val zone = systemZone()
val 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 +235,11 @@ internal suspend fun Context.loadMonthWidgetSource(centerIndex: Int? = null): Mo
.plus(2, DateTimeUnit.WEEK)
.atTime(23, 59, 59)
.toInstant(zone)
val instances = ep.calendarRepository().instances(from..to).first()
return MonthWidgetSource.Ready(
today = anchor,
weekStart = weekStart,
instances = instances,
today = today(zone),
weekStart = settings.weekStart.resolveFirstDay(Locale.getDefault()),
soften = settings.soften,
instances = ep.calendarRepository().instances(from..to).first(),
months = months,
).also { MonthWidgetCache.data = it }
)
}
@@ -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 =
@@ -0,0 +1,137 @@
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.calendula.widget.agenda.AgendaWidget
import de.jeanlucmakiola.calendula.widget.agenda.AgendaWidgetReceiver
import de.jeanlucmakiola.calendula.widget.month.MonthWidget
import de.jeanlucmakiola.calendula.widget.month.MonthWidgetReceiver
import de.jeanlucmakiola.floret.glance.WidgetRefresher
import de.jeanlucmakiola.floret.glance.WidgetRollover
import de.jeanlucmakiola.floret.glance.updatePlaced
import 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<Class<*>> = listOf(
MonthWidgetReceiver::class.java,
AgendaWidgetReceiver::class.java,
)
/** The app's own midnight wake-up (#228). */
internal const val ACTION_WIDGET_ROLLOVER = "de.jeanlucmakiola.calendula.widget.ROLLOVER"
/** Fixed: there is only ever one rollover alarm, and re-arming must replace it. */
private const val ROLLOVER_REQUEST_CODE = 0x0DA1
internal fun widgetRolloverPendingIntent(context: Context): PendingIntent = PendingIntent.getBroadcast(
context,
ROLLOVER_REQUEST_CODE,
Intent(context, WidgetUpdateReceiver::class.java).setAction(ACTION_WIDGET_ROLLOVER),
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
)
/** Arm the next midnight rollover, or cancel it when no widget is placed. Idempotent. */
internal fun syncWidgetRollover(context: Context) =
WidgetRollover.sync(context, WIDGET_RECEIVER_CLASSES, ::widgetRolloverPendingIntent)
/**
* Reloads and redraws every placed widget, a running session included: the
* refresh stamp moves, and both widgets key their data load on it (#420).
*/
internal suspend fun redrawWidgets(context: Context) {
redrawAgendaWidgets(context)
redrawMonthWidgets(context)
}
private suspend fun redrawAgendaWidgets(context: Context) =
AgendaWidget().updatePlaced(context, AgendaWidgetReceiver::class.java, bumpRefreshStamp = true)
/** Redraws only the agenda widgets with no options of their own, which still follow the app's. */
private suspend fun redrawUnconfiguredAgendaWidgets(context: Context) {
val appContext = context.applicationContext
val ids = AppWidgetManager.getInstance(appContext)
?.getAppWidgetIds(ComponentName(appContext, AgendaWidgetReceiver::class.java)) ?: return
val glanceManager = GlanceAppWidgetManager(appContext)
ids.forEach { appWidgetId ->
val glanceId = glanceManager.getGlanceIdBy(appWidgetId)
if (!appContext.widgetState(glanceId).hasAgendaOptions) AgendaWidget().updateWithRefresh(appContext, glanceId)
}
}
private suspend fun redrawMonthWidgets(context: Context) =
MonthWidget().updatePlaced(context, MonthWidgetReceiver::class.java, bumpRefreshStamp = true)
/** 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
* agenda widgets that have none 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 agenda = WidgetRefresher(scope) { redrawAgendaWidgets(context) }
val unconfiguredAgenda = WidgetRefresher(scope) { redrawUnconfiguredAgendaWidgets(context) }
val month = WidgetRefresher(scope) { redrawMonthWidgets(context) }
watch(repository.localChanges) {
lastLocalChange.set(SystemClock.elapsedRealtime())
agenda.request()
month.request()
}
watch(prefs.widgetSettings.map { it.shared }.distinctUntilChanged().drop(1)) {
agenda.request()
month.request()
}
watch(prefs.widgetSettings.map { it.agendaDefaults }.distinctUntilChanged().drop(1)) {
unconfiguredAgenda.request()
}
}
private fun watch(changes: Flow<*>, onChange: () -> Unit) {
scope.launch(Dispatchers.IO, start = CoroutineStart.UNDISPATCHED) {
changes.collect { onChange() }
}
}
}
@@ -1,115 +0,0 @@
package de.jeanlucmakiola.calendula.widget
import android.app.AlarmManager
import android.app.PendingIntent
import android.appwidget.AppWidgetManager
import android.content.ComponentName
import android.content.Context
import android.content.Intent
import androidx.core.content.getSystemService
import de.jeanlucmakiola.calendula.widget.agenda.AgendaWidgetReceiver
import de.jeanlucmakiola.calendula.widget.month.MonthWidgetReceiver
import kotlinx.datetime.DateTimeUnit
import kotlinx.datetime.TimeZone
import kotlinx.datetime.atStartOfDayIn
import kotlinx.datetime.plus
import kotlinx.datetime.toLocalDateTime
import kotlin.time.Clock
import kotlin.time.Duration.Companion.hours
import kotlin.time.Duration.Companion.seconds
import kotlin.time.Instant
/**
* Holds the app's own wake-up for the next local midnight, so the home-screen
* widgets roll "today" over on the day boundary (#228).
*
* The widgets used to lean on `ACTION_DATE_CHANGED`, which is not an exempted
* implicit broadcast — a manifest-declared receiver has not been given it since
* Android 8, leaving only the throttled `updatePeriodMillis`.
*
* Exactly one alarm exists at a time and every firing re-arms the next, the same
* shape as [de.jeanlucmakiola.calendula.data.reminders.ReminderAlarmScheduler].
* Deliberately **inexact**: `setAndAllowWhileIdle` needs no permission and
* survives doze (plain `set` does not), a rollover a few minutes late is
* invisible on a sleeping screen, and exact alarms stay reserved for snooze.
*/
object WidgetRolloverScheduler {
/**
* Fire just *after* midnight: an alarm delivered a few milliseconds early
* would still read the old date and re-arm for an instant later.
*/
internal val ROLLOVER_SLACK = 5.seconds
/**
* Arm the next rollover, or cancel a pending one when no widget is placed.
* Idempotent, so every trigger (boot, app start, widget added/removed, the
* rollover itself, a clock or timezone change) can just call it.
*/
fun sync(context: Context) {
val appContext = context.applicationContext
val alarmManager = appContext.getSystemService<AlarmManager>() ?: return
val pendingIntent = rolloverPendingIntent(appContext)
if (!hasPlacedWidgets(appContext)) {
alarmManager.cancel(pendingIntent)
return
}
val triggerAt = nextRolloverAt(Clock.System.now(), TimeZone.currentSystemDefault())
alarmManager.setAndAllowWhileIdle(
AlarmManager.RTC_WAKEUP, triggerAt.toEpochMilliseconds(), pendingIntent,
)
}
/**
* The instant just after the next local midnight following [now] in [zone].
*
* The *actual* start of day, not 00:00, so it holds where a DST jump means
* midnight never happens (Havana) and where a date-line move skips a whole
* local date (Apia, December 2011) — the loop walks on to the next real day.
*
* The mirror case — a zone rewinding *across* midnight, so the day starts
* twice — resolves to the earlier start and runs an hour ahead of the clock.
* No live tz entry does that (Brazil dropped DST in 2019) and
* `updatePeriodMillis` covers it, so it isn't worth state to detect.
*/
fun nextRolloverAt(now: Instant, zone: TimeZone): Instant {
val date = now.toLocalDateTime(zone).date
var days = 1
while (days <= MAX_LOOKAHEAD_DAYS) {
val candidate = date.plus(days, DateTimeUnit.DAY).atStartOfDayIn(zone) + ROLLOVER_SLACK
if (candidate > now) return candidate
days++
}
// Unreachable for any zone in the tz database. Deliberately an hour and
// not the slack: a 5-second retry would just hit this branch again and
// wake the device in a loop.
return now + 1.hours
}
private fun hasPlacedWidgets(context: Context): Boolean {
val manager = AppWidgetManager.getInstance(context) ?: return false
return PROVIDERS.any {
manager.getAppWidgetIds(ComponentName(context, it)).isNotEmpty()
}
}
private fun rolloverPendingIntent(context: Context): PendingIntent =
PendingIntent.getBroadcast(
context,
ROLLOVER_REQUEST_CODE,
Intent(context, WidgetUpdateReceiver::class.java)
.setAction(WidgetUpdateReceiver.ACTION_ROLLOVER),
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
)
private val PROVIDERS = listOf(
MonthWidgetReceiver::class.java,
AgendaWidgetReceiver::class.java,
)
/** Fixed: there is only ever one rollover alarm, and re-arming must replace it. */
private const val ROLLOVER_REQUEST_CODE = 0x0DA1
/** A gap of more than a couple of days does not exist in any tz database entry. */
private const val MAX_LOOKAHEAD_DAYS = 3
}
@@ -1,39 +1,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)
}
/**
@@ -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)
}
@@ -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. */
@@ -3,38 +3,22 @@ package de.jeanlucmakiola.calendula.widget.agenda
import de.jeanlucmakiola.calendula.domain.isDeclined
import de.jeanlucmakiola.calendula.widget.glanceDeclinedDecoration
import android.content.Context
import android.content.res.Configuration
import androidx.compose.runtime.Composable
import androidx.compose.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 +31,64 @@ 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.formatCalendarTitle
import de.jeanlucmakiola.calendula.ui.common.formatTimeOfDay
import de.jeanlucmakiola.floret.locale.localizedDateFormatter
import de.jeanlucmakiola.calendula.ui.common.eventAccent
import de.jeanlucmakiola.calendula.ui.common.eventFill
import de.jeanlucmakiola.calendula.ui.common.eventInk
import de.jeanlucmakiola.calendula.ui.common.SECONDARY_INK_ALPHA
import de.jeanlucmakiola.calendula.widget.AgendaWidgetData
import de.jeanlucmakiola.calendula.widget.CalendulaGlanceTheme
import de.jeanlucmakiola.calendula.widget.WidgetSize
import de.jeanlucmakiola.calendula.widget.loadAgendaWidgetData
import de.jeanlucmakiola.calendula.widget.CalendulaWidgetRoot
import de.jeanlucmakiola.calendula.widget.WidgetAppearance
import de.jeanlucmakiola.calendula.widget.loadAgendaWidget
import de.jeanlucmakiola.calendula.widget.widgetAppearance
import de.jeanlucmakiola.floret.components.Position
import de.jeanlucmakiola.floret.components.positionOf
import de.jeanlucmakiola.floret.glance.LocalWidgetBackground
import de.jeanlucmakiola.floret.glance.WidgetBackground
import de.jeanlucmakiola.floret.glance.WidgetGroupedRow
import de.jeanlucmakiola.floret.glance.themedColor
import de.jeanlucmakiola.floret.glance.wallpaperBacking
import de.jeanlucmakiola.floret.prefs.ThemeMode
import androidx.datastore.preferences.core.Preferences
import androidx.glance.currentState
import de.jeanlucmakiola.floret.glance.WidgetData
import de.jeanlucmakiola.floret.glance.currentRefreshStamp
import de.jeanlucmakiola.floret.glance.refreshStamp
import de.jeanlucmakiola.calendula.widget.systemZone
import de.jeanlucmakiola.calendula.widget.today
import kotlinx.datetime.DateTimeUnit
import kotlinx.datetime.LocalDate
import kotlinx.datetime.TimeZone
import kotlinx.datetime.plus
import kotlinx.datetime.toJavaDayOfWeek
import kotlinx.datetime.toLocalDateTime
import kotlin.time.Instant
import java.util.Locale
/**
* 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() {
@@ -132,31 +102,41 @@ class AgendaWidget : GlanceAppWidget() {
override val sizeMode = SizeMode.Single
override suspend fun provideGlance(context: Context, id: GlanceId) {
val stored = getAppWidgetState<Preferences>(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<Preferences>().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 +146,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<AgendaWidgetData.Ready>(data) }
// A range wider than what was loaded reloads in a live session,
// where provideGlance doesn't run again (#363).
LaunchedEffect(range) {
val end = loaded.today.plus(
range.dayCount(loaded.today, loaded.weekStart) - 1,
DateTimeUnit.DAY,
)
if (end > loaded.loadedThrough) {
(context.loadAgendaWidgetData(range) as? AgendaWidgetData.Ready)
?.let { loaded = it }
}
}
AgendaRows(data = loaded, range = range, dark = dark, metrics = metrics)
}
is AgendaWidgetData.Ready -> AgendaRows(data = data, metrics = metrics, preview = preview)
}
}
}
@@ -224,24 +179,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 +200,230 @@ 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 = agendaCardColor()
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<AgendaRow>): List<Position> = buildList {
var start = 0
for (end in 1..rows.size) {
if (end == rows.size || rows[end].date != rows[start].date) {
for (i in start until end) add(positionOf(i - start, end - start))
start = end
}
}
}
@Composable
private fun 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<RefreshAgendaAction>()),
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()))),
)
}
}
/** Cards sit one tone off the root fill, and stay opaque over a wallpaper (#292). */
@Composable
private fun IconButton(
resId: Int,
contentDescription: String,
onClick: GlanceModifier,
metrics: AgendaMetrics,
) {
Box(
modifier = GlanceModifier.size(metrics.iconBox).then(onClick),
contentAlignment = Alignment.Center,
) {
Image(
provider = ImageProvider(resId),
contentDescription = contentDescription,
colorFilter = ColorFilter.tint(GlanceTheme.colors.onSurfaceVariant),
modifier = GlanceModifier.size(metrics.iconImage),
)
}
private fun agendaCardColor(): ColorProvider = when (LocalWidgetBackground.current) {
WidgetBackground.SOLID -> GlanceTheme.colors.surfaceVariant
WidgetBackground.TINTED, WidgetBackground.TRANSPARENT -> GlanceTheme.colors.widgetBackground
}
/** The month name ("November", with its year outside the current one) above a new month's first day. */
@Composable
private fun DayHeaderRow(date: LocalDate, today: LocalDate, metrics: AgendaMetrics) {
val context = androidx.glance.LocalContext.current
private fun MonthLabel(date: LocalDate, today: LocalDate, metrics: AgendaMetrics) {
Text(
text = agendaDayLabel(context, date, today),
text = 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 +437,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,6 +465,7 @@ private fun WidgetMessage(resId: Int, metrics: AgendaMetrics) {
Text(
text = context.getString(resId),
style = TextStyle(color = GlanceTheme.colors.onSurfaceVariant, fontSize = metrics.message),
modifier = GlanceModifier.wallpaperBacking().padding(horizontal = 12.dp, vertical = 8.dp),
)
}
}
@@ -501,6 +487,16 @@ 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,
@@ -1,10 +1,13 @@
package de.jeanlucmakiola.calendula.widget.agenda
import android.annotation.SuppressLint
import android.appwidget.AppWidgetManager
import android.content.Context
import androidx.glance.appwidget.GlanceAppWidget
import androidx.glance.appwidget.GlanceAppWidgetReceiver
import de.jeanlucmakiola.calendula.widget.WidgetRolloverScheduler
import de.jeanlucmakiola.calendula.widget.syncWidgetRollover
import de.jeanlucmakiola.floret.glance.reloadOnUpdate
import de.jeanlucmakiola.floret.glance.restoreState
/**
* Host-facing receiver for the agenda widget. Declared in the manifest with the
@@ -17,28 +20,35 @@ class AgendaWidgetReceiver : GlanceAppWidgetReceiver() {
/** First agenda widget placed — start rolling "today" over at midnight (#228). */
override fun onEnabled(context: Context) {
super.onEnabled(context)
WidgetRolloverScheduler.sync(context)
syncWidgetRollover(context)
}
/**
* Last agenda widget removed. [WidgetRolloverScheduler.sync] cancels only if
* Last agenda widget removed. [syncWidgetRollover] cancels only if
* no month widget is left either.
*/
override fun onDisabled(context: Context) {
super.onDisabled(context)
WidgetRolloverScheduler.sync(context)
syncWidgetRollover(context)
}
/** A backup restore or device transfer renumbered the widgets: keep each one's settings (#292). */
override fun onRestored(context: Context, oldWidgetIds: IntArray, newWidgetIds: IntArray) {
restoreState(context, oldWidgetIds, newWidgetIds)
}
/**
* Self-heal on the system's own `updatePeriodMillis` wake-up — see
* [de.jeanlucmakiola.calendula.widget.month.MonthWidgetReceiver.onUpdate].
*/
@SuppressLint("MissingSuperCall")
override fun onUpdate(
context: Context,
appWidgetManager: AppWidgetManager,
appWidgetIds: IntArray,
) {
super.onUpdate(context, appWidgetManager, appWidgetIds)
WidgetRolloverScheduler.sync(context)
// In place of super, which only recomposes and would take the goAsync handle.
reloadOnUpdate(context, appWidgetIds)
syncWidgetRollover(context)
}
}
@@ -0,0 +1,31 @@
package de.jeanlucmakiola.calendula.widget.config
import android.appwidget.AppWidgetManager
import android.content.ComponentName
import android.content.Context
import androidx.glance.appwidget.GlanceAppWidgetManager
import de.jeanlucmakiola.calendula.widget.WidgetAppearance
import de.jeanlucmakiola.calendula.widget.widgetAppearance
import de.jeanlucmakiola.calendula.widget.widgetState
/** One of the app's widgets on the home screen, for the settings list. */
data class PlacedWidget(val appWidgetId: Int, val kind: WidgetKind, val appearance: WidgetAppearance)
/**
* Every placed widget, agenda ones first, each in placement order. An id the
* host bound but never laid out (a placement it abandoned without freeing the
* id) has no size yet and is left out, as is one removed while this reads.
*/
suspend fun Context.placedWidgets(): List<PlacedWidget> {
val manager = AppWidgetManager.getInstance(this) ?: return emptyList()
val glanceManager = GlanceAppWidgetManager(this)
return WidgetKind.entries.flatMap { kind ->
manager.getAppWidgetIds(ComponentName(this, kind.receiver)).sorted().mapNotNull { id ->
if (manager.getAppWidgetOptions(id).getInt(AppWidgetManager.OPTION_APPWIDGET_MIN_WIDTH) <= 0) {
return@mapNotNull null
}
val glanceId = runCatching { glanceManager.getGlanceIdBy(id) }.getOrNull() ?: return@mapNotNull null
PlacedWidget(id, kind, widgetState(glanceId).widgetAppearance)
}
}
}
@@ -0,0 +1,130 @@
package de.jeanlucmakiola.calendula.widget.config
import android.appwidget.AppWidgetManager
import android.content.Context
import android.content.Intent
import android.os.Bundle
import android.widget.Toast
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.activity.viewModels
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.platform.LocalContext
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.lifecycleScope
import androidx.lifecycle.repeatOnLifecycle
import dagger.hilt.android.AndroidEntryPoint
import de.jeanlucmakiola.calendula.R
import de.jeanlucmakiola.calendula.data.prefs.SettingsKeys
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
import de.jeanlucmakiola.calendula.domain.FontRole
import de.jeanlucmakiola.calendula.ui.theme.AppFontSettings
import de.jeanlucmakiola.calendula.ui.theme.CalendulaTheme
import de.jeanlucmakiola.calendula.ui.theme.WindowThemeCache
import de.jeanlucmakiola.calendula.ui.theme.calendulaTypography
import de.jeanlucmakiola.calendula.ui.theme.resolveFontFamily
import de.jeanlucmakiola.floret.prefs.ThemeMode
import de.jeanlucmakiola.floret.prefs.get
import de.jeanlucmakiola.floret.prefs.isDark
import de.jeanlucmakiola.floret.prefs.isPureBlack
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.launch
import javax.inject.Inject
/**
* A widget's config screen (#292): opened by the launcher when the widget is
* placed, from its "Edit widget" action on Android 12+, and from the app's
* widget settings. Shows over the wallpaper, so a transparent widget previews
* against the real home screen.
*/
@AndroidEntryPoint
class WidgetConfigActivity : ComponentActivity() {
@Inject lateinit var settingsPrefs: SettingsPrefs
private val viewModel: WidgetConfigViewModel by viewModels()
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
val appWidgetId = viewModel.appWidgetId
// Backing out of a fresh placement leaves no unconfigured widget behind.
setResult(RESULT_CANCELED, result(appWidgetId))
if (appWidgetId == AppWidgetManager.INVALID_APPWIDGET_ID) {
finish()
return
}
enableEdgeToEdge()
lifecycleScope.launch {
repeatOnLifecycle(Lifecycle.State.STARTED) {
viewModel.events.collect { event ->
when (event) {
WidgetConfigEvent.SAVED -> {
setResult(RESULT_OK, result(appWidgetId))
finish()
}
WidgetConfigEvent.SAVE_FAILED ->
Toast.makeText(this@WidgetConfigActivity, R.string.widget_config_save_failed, Toast.LENGTH_SHORT).show()
}
}
}
}
val fontSettings = settingsPrefs.data.map {
AppFontSettings(
brand = it[SettingsKeys.brandFont],
plain = it[SettingsKeys.plainFont],
brandStamp = it[SettingsKeys.brandFontStamp],
plainStamp = it[SettingsKeys.plainFontStamp],
)
}
// Seeded from MainActivity's mirrored copy, so an app theme override
// shows from the first frame.
val cachedTheme = WindowThemeCache.read(this) ?: ThemeMode.SYSTEM
val cachedDynamicColor = WindowThemeCache.readDynamicColor(this) ?: true
setContent {
val themeMode by settingsPrefs.themeMode.collectAsStateWithLifecycle(cachedTheme)
val dynamicColor by settingsPrefs.dynamicColor.collectAsStateWithLifecycle(cachedDynamicColor)
val fonts by fontSettings.collectAsStateWithLifecycle(initialValue = null)
val context = LocalContext.current
// Nothing until the font choice is known, rather than a frame in the default one.
val typography = remember(fonts, context) {
fonts?.let {
calendulaTypography(
brand = resolveFontFamily(it.brand, FontRole.BRAND, context),
plain = resolveFontFamily(it.plain, FontRole.PLAIN, context),
)
}
} ?: return@setContent
CalendulaTheme(
darkTheme = themeMode.isDark(isSystemInDarkTheme()),
dynamicColor = dynamicColor,
pureBlack = themeMode.isPureBlack,
typography = typography,
) {
val state by viewModel.state.collectAsStateWithLifecycle()
if (state is WidgetConfigUiState.Missing) LaunchedEffect(Unit) { finish() }
WidgetConfigScreen(
state = state,
onClose = ::finish,
onSave = viewModel::save,
onRetry = viewModel::retry,
onAppearance = viewModel::setAppearance,
onAgendaOptions = viewModel::setAgendaOptions,
)
}
}
}
private fun result(appWidgetId: Int) = Intent().putExtra(AppWidgetManager.EXTRA_APPWIDGET_ID, appWidgetId)
companion object {
/** Opens placed widget [appWidgetId]'s config screen from inside the app. */
fun intent(context: Context, appWidgetId: Int): Intent =
Intent(context, WidgetConfigActivity::class.java)
.putExtra(AppWidgetManager.EXTRA_APPWIDGET_ID, appWidgetId)
}
}
@@ -0,0 +1,328 @@
package de.jeanlucmakiola.calendula.widget.config
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Close
import androidx.compose.material3.Button
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.FilledTonalButton
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.DpSize
import androidx.compose.ui.unit.dp
import de.jeanlucmakiola.calendula.R
import de.jeanlucmakiola.calendula.data.prefs.PastEventDisplay
import de.jeanlucmakiola.calendula.ui.common.AgendaRangePicker
import de.jeanlucmakiola.calendula.ui.common.agendaRangeLabel
import de.jeanlucmakiola.calendula.ui.settings.pastEventDisplayLabelRes
import de.jeanlucmakiola.calendula.widget.AgendaWidgetOptions
import de.jeanlucmakiola.calendula.widget.WidgetAppearance
import de.jeanlucmakiola.calendula.widget.WidgetSize
import de.jeanlucmakiola.calendula.widget.WidgetTheme
import de.jeanlucmakiola.calendula.widget.agenda.AgendaWidgetContent
import de.jeanlucmakiola.calendula.widget.agenda.metricsFor
import de.jeanlucmakiola.calendula.widget.month.MonthWidgetContent
import de.jeanlucmakiola.floret.components.GroupedChoiceRow
import de.jeanlucmakiola.floret.components.GroupedHint
import de.jeanlucmakiola.floret.components.GroupedRow
import de.jeanlucmakiola.floret.components.GroupedSectionHeader
import de.jeanlucmakiola.floret.components.GroupedSwitchRow
import de.jeanlucmakiola.floret.components.OptionPicker
import de.jeanlucmakiola.floret.components.Position
import de.jeanlucmakiola.floret.glance.GlanceWidgetPreview
import de.jeanlucmakiola.floret.glance.WallpaperWindow
import de.jeanlucmakiola.floret.glance.WidgetBackground
import kotlin.math.roundToInt
private val PreviewMaxHeight = 280.dp
@OptIn(ExperimentalMaterial3Api::class)
@Composable
internal fun WidgetConfigScreen(
state: WidgetConfigUiState,
onClose: () -> Unit,
onSave: () -> Unit,
onRetry: () -> Unit,
onAppearance: (WidgetAppearance) -> Unit,
onAgendaOptions: (AgendaWidgetOptions) -> Unit,
) {
val ready = state as? WidgetConfigUiState.Ready
val surface = MaterialTheme.colorScheme.surface
Column(modifier = Modifier.fillMaxSize()) {
TopAppBar(
title = {
Text(
text = ready?.let { stringResource(it.kind.label) }.orEmpty(),
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
},
navigationIcon = {
IconButton(onClick = onClose) {
Icon(Icons.Default.Close, contentDescription = stringResource(R.string.event_edit_close))
}
},
actions = {
Button(
onClick = onSave,
enabled = ready != null && !ready.saving,
modifier = Modifier.padding(end = 8.dp),
) {
Text(stringResource(R.string.dialog_save))
}
},
colors = TopAppBarDefaults.topAppBarColors(containerColor = surface),
)
when (state) {
WidgetConfigUiState.Loading, WidgetConfigUiState.Missing -> Box(
modifier = Modifier.fillMaxSize().background(surface),
contentAlignment = Alignment.Center,
) {
CircularProgressIndicator()
}
WidgetConfigUiState.Failed -> Column(
modifier = Modifier.fillMaxSize().background(surface).padding(24.dp),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally,
) {
Text(
text = stringResource(R.string.widget_config_load_failed),
style = MaterialTheme.typography.bodyLarge,
textAlign = TextAlign.Center,
)
Spacer(Modifier.height(16.dp))
FilledTonalButton(onClick = onRetry) { Text(stringResource(R.string.state_retry)) }
}
is WidgetConfigUiState.Ready -> {
WallpaperWindow(color = surface) {
val maxSize = DpSize(maxWidth, PreviewMaxHeight)
when (val preview = state.preview) {
null -> Box(
modifier = Modifier.padding(vertical = 16.dp).height(maxSize.height / 2),
contentAlignment = Alignment.Center,
) {
if (state.previewFailed) PreviewFailed() else CircularProgressIndicator()
}
else -> GlanceWidgetPreview(
size = state.placedSize,
maxSize = maxSize,
preview,
state.appearance,
state.appTheme,
modifier = Modifier.padding(vertical = 16.dp),
placeholder = { CircularProgressIndicator() },
failure = { PreviewFailed() },
) {
when (preview) {
is WidgetPreviewData.Agenda ->
AgendaWidgetContent(preview.data, state.appearance, state.appTheme, preview = true)
is WidgetPreviewData.Month ->
MonthWidgetContent(preview.source, preview.index, state.appearance, state.appTheme)
}
}
}
}
ConfigOptions(
state = state,
onAppearance = onAppearance,
onAgendaOptions = onAgendaOptions,
modifier = Modifier
.weight(1f)
.background(surface),
)
}
}
}
}
@Composable
private fun PreviewFailed() {
Text(
text = stringResource(R.string.widget_config_preview_failed),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurface,
textAlign = TextAlign.Center,
modifier = Modifier
.background(MaterialTheme.colorScheme.surface, MaterialTheme.shapes.large)
.padding(horizontal = 16.dp, vertical = 8.dp),
)
}
internal val WidgetKind.label: Int
get() = when (this) {
WidgetKind.AGENDA -> R.string.widget_agenda_label
WidgetKind.MONTH -> R.string.widget_month_label
}
@Composable
private fun ConfigOptions(
state: WidgetConfigUiState.Ready,
onAppearance: (WidgetAppearance) -> Unit,
onAgendaOptions: (AgendaWidgetOptions) -> Unit,
modifier: Modifier = Modifier,
) {
Column(
modifier = modifier
.verticalScroll(rememberScrollState())
.navigationBarsPadding()
.padding(bottom = 16.dp),
) {
GroupedSectionHeader(stringResource(R.string.settings_section_appearance))
GroupedChoiceRow(
title = stringResource(R.string.widget_config_background),
options = WidgetBackground.entries,
selected = state.appearance.background,
label = { backgroundLabel(it) },
onSelect = { onAppearance(state.appearance.copy(background = it)) },
position = Position.Top,
)
GroupedChoiceRow(
title = stringResource(R.string.settings_theme),
options = WidgetTheme.entries,
selected = state.appearance.theme,
label = { themeLabel(it) },
onSelect = { onAppearance(state.appearance.copy(theme = it)) },
position = Position.Bottom,
)
state.agenda?.let { options ->
AgendaOptions(options = options, state = state, onChange = onAgendaOptions)
}
}
}
@Composable
private fun AgendaOptions(
options: AgendaWidgetOptions,
state: WidgetConfigUiState.Ready,
onChange: (AgendaWidgetOptions) -> Unit,
) {
var showRange by rememberSaveable { mutableStateOf(false) }
var showSize by rememberSaveable { mutableStateOf(false) }
var showPast by rememberSaveable { mutableStateOf(false) }
GroupedSectionHeader(stringResource(R.string.widget_config_content))
GroupedRow(
title = stringResource(R.string.widget_config_range),
summary = agendaRangeLabel(options.range),
position = Position.Top,
onClick = { showRange = true },
)
GroupedRow(
title = stringResource(R.string.widget_config_text_size),
summary = widgetSizeLabel(options.textSize),
position = Position.Middle,
onClick = { showSize = true },
)
GroupedRow(
title = stringResource(R.string.settings_past_events),
summary = stringResource(pastEventDisplayLabelRes(options.pastDisplay)),
position = Position.Middle,
onClick = { showPast = true },
)
GroupedSwitchRow(
title = stringResource(R.string.settings_agenda_show_today),
checked = options.showToday,
onCheckedChange = { onChange(options.copy(showToday = it)) },
position = Position.Bottom,
)
if (showRange) {
AgendaRangePicker(
title = stringResource(R.string.widget_config_range),
description = stringResource(R.string.settings_agenda_widget_range_hint),
selected = options.range,
weekStart = state.weekStart,
onSelect = { onChange(options.copy(range = it)) },
onDismiss = { showRange = false },
)
}
if (showSize) {
OptionPicker(
title = stringResource(R.string.widget_config_text_size),
header = { GroupedHint(stringResource(R.string.settings_widget_size_hint), verticalPadding = 8.dp) },
predictiveBack = true,
options = WidgetSize.entries,
selected = options.textSize,
label = { widgetSizeLabel(it) },
summary = { widgetSizeSummary(it) },
onSelect = { onChange(options.copy(textSize = it)) },
onDismiss = { showSize = false },
)
}
if (showPast) {
OptionPicker(
title = stringResource(R.string.settings_past_events),
header = { GroupedHint(stringResource(R.string.settings_past_events_hint), verticalPadding = 8.dp) },
predictiveBack = true,
options = PastEventDisplay.entries,
selected = options.pastDisplay,
label = { stringResource(pastEventDisplayLabelRes(it)) },
onSelect = { onChange(options.copy(pastDisplay = it)) },
onDismiss = { showPast = false },
)
}
}
@Composable
internal fun backgroundLabel(background: WidgetBackground): String = stringResource(
when (background) {
WidgetBackground.SOLID -> R.string.widget_config_background_solid
WidgetBackground.TINTED -> R.string.widget_config_background_tinted
WidgetBackground.TRANSPARENT -> R.string.widget_config_background_transparent
},
)
@Composable
internal fun themeLabel(theme: WidgetTheme): String = stringResource(
when (theme) {
WidgetTheme.FOLLOW_APP -> R.string.widget_config_theme_follow_app
WidgetTheme.LIGHT -> R.string.settings_theme_light
WidgetTheme.DARK -> R.string.settings_theme_dark
},
)
/**
* What a size step does, as a percentage of the smallest one's event text.
* Derived from the widget's own [metricsFor] table rather than hardcoded.
*/
@Composable
private fun widgetSizeSummary(size: WidgetSize): String {
val base = metricsFor(WidgetSize.SMALL).eventTitle.value
val percent = (metricsFor(size).eventTitle.value / base * 100f).roundToInt()
return stringResource(R.string.settings_widget_size_summary, percent)
}
@Composable
private fun widgetSizeLabel(size: WidgetSize): String = stringResource(
when (size) {
WidgetSize.SMALL -> R.string.settings_widget_size_small
WidgetSize.MEDIUM -> R.string.settings_widget_size_medium
WidgetSize.LARGE -> R.string.settings_widget_size_large
WidgetSize.EXTRA_LARGE -> R.string.settings_widget_size_extra_large
},
)
@@ -0,0 +1,242 @@
package de.jeanlucmakiola.calendula.widget.config
import android.appwidget.AppWidgetManager
import android.content.Context
import androidx.compose.ui.unit.DpSize
import androidx.compose.ui.unit.dp
import androidx.glance.appwidget.GlanceAppWidget
import androidx.glance.appwidget.GlanceAppWidgetManager
import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import dagger.hilt.android.qualifiers.ApplicationContext
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
import de.jeanlucmakiola.calendula.data.prefs.resolveFirstDay
import de.jeanlucmakiola.calendula.widget.AgendaWidgetData
import de.jeanlucmakiola.calendula.widget.AgendaWidgetOptions
import de.jeanlucmakiola.calendula.widget.MonthWidgetSource
import de.jeanlucmakiola.calendula.widget.WidgetAppearance
import de.jeanlucmakiola.calendula.widget.agenda.AgendaWidget
import de.jeanlucmakiola.calendula.widget.agenda.AgendaWidgetReceiver
import de.jeanlucmakiola.calendula.widget.loadAgendaWidgetData
import de.jeanlucmakiola.calendula.widget.loadThemedMonthSource
import de.jeanlucmakiola.calendula.widget.month.MonthWidget
import de.jeanlucmakiola.calendula.widget.month.MonthWidgetReceiver
import de.jeanlucmakiola.calendula.widget.monthIndexOf
import de.jeanlucmakiola.calendula.widget.monthWindowCenter
import de.jeanlucmakiola.calendula.widget.agendaWidgetOptions
import de.jeanlucmakiola.calendula.widget.putAgendaOptions
import de.jeanlucmakiola.calendula.widget.putAppearance
import de.jeanlucmakiola.calendula.widget.systemZone
import de.jeanlucmakiola.calendula.widget.today
import de.jeanlucmakiola.calendula.widget.widgetAppearance
import de.jeanlucmakiola.calendula.widget.widgetSettings
import de.jeanlucmakiola.calendula.widget.widgetState
import de.jeanlucmakiola.floret.glance.updateWithRefresh
import de.jeanlucmakiola.floret.prefs.ThemeMode
import javax.inject.Inject
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Job
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.receiveAsFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.datetime.DayOfWeek
import java.util.Locale
/** The widgets a config screen can edit, told apart by their receiver. */
enum class WidgetKind(val receiver: Class<*>) {
AGENDA(AgendaWidgetReceiver::class.java),
MONTH(MonthWidgetReceiver::class.java),
;
fun widget(): GlanceAppWidget = when (this) {
AGENDA -> AgendaWidget()
MONTH -> MonthWidget()
}
companion object {
fun of(context: Context, appWidgetId: Int): WidgetKind? {
val provider = AppWidgetManager.getInstance(context)?.getAppWidgetInfo(appWidgetId)?.provider ?: return null
return entries.firstOrNull { it.receiver.name == provider.className }
}
}
}
/** What the preview draws, loaded with the draft's options. */
sealed interface WidgetPreviewData {
data class Agenda(val data: AgendaWidgetData) : WidgetPreviewData
data class Month(val source: MonthWidgetSource, val index: Int) : WidgetPreviewData
}
sealed interface WidgetConfigUiState {
data object Loading : WidgetConfigUiState
/** The widget is gone, or isn't one of ours; the screen closes. */
data object Missing : WidgetConfigUiState
/** Its stored options couldn't be read. */
data object Failed : WidgetConfigUiState
data class Ready(
val kind: WidgetKind,
val appearance: WidgetAppearance,
/** Null for a widget with no content options. */
val agenda: AgendaWidgetOptions?,
val appTheme: ThemeMode,
val weekStart: DayOfWeek,
/** The widget's size on the home screen, which the preview is laid out at. */
val placedSize: DpSize,
/** Null until the first preview load lands. */
val preview: WidgetPreviewData?,
/** The first preview load failed, so there's nothing to show. */
val previewFailed: Boolean = false,
val saving: Boolean = false,
) : WidgetConfigUiState
}
/** One-time outcomes of a save, for the screen that's showing when they land. */
enum class WidgetConfigEvent { SAVED, SAVE_FAILED }
/**
* Backs [WidgetConfigActivity] (#292): the placed widget's stored options as an
* editable draft, a preview loaded with that draft, and the save that writes it
* into the widget's Glance state and redraws it.
*/
@HiltViewModel
class WidgetConfigViewModel @Inject constructor(
@ApplicationContext private val context: Context,
private val prefs: SettingsPrefs,
savedState: SavedStateHandle,
) : ViewModel() {
val appWidgetId: Int = savedState[AppWidgetManager.EXTRA_APPWIDGET_ID] ?: AppWidgetManager.INVALID_APPWIDGET_ID
private val _state = MutableStateFlow<WidgetConfigUiState>(WidgetConfigUiState.Loading)
val state: StateFlow<WidgetConfigUiState> = _state.asStateFlow()
private val _events = Channel<WidgetConfigEvent>(Channel.BUFFERED)
val events: Flow<WidgetConfigEvent> = _events.receiveAsFlow()
private var previewJob: Job? = null
init {
viewModelScope.launch { load() }
}
fun retry() {
if (_state.value != WidgetConfigUiState.Failed) return
_state.value = WidgetConfigUiState.Loading
viewModelScope.launch { load() }
}
private suspend fun load() {
val kind = WidgetKind.of(context, appWidgetId)
if (kind == null) {
_state.value = WidgetConfigUiState.Missing
return
}
_state.value = try {
val stored = context.widgetState(GlanceAppWidgetManager(context).getGlanceIdBy(appWidgetId))
val settings = prefs.widgetSettings.first()
WidgetConfigUiState.Ready(
kind = kind,
appearance = stored.widgetAppearance,
agenda = if (kind == WidgetKind.AGENDA) stored.agendaWidgetOptions(settings.agendaDefaults) else null,
appTheme = settings.themeMode,
weekStart = settings.weekStart.resolveFirstDay(Locale.getDefault()),
placedSize = placedSize(kind),
preview = null,
)
} catch (e: CancellationException) {
throw e
} catch (_: IllegalArgumentException) {
// getGlanceIdBy: the widget was removed meanwhile.
WidgetConfigUiState.Missing
} catch (_: Exception) {
WidgetConfigUiState.Failed
}
reloadPreview()
}
/** The size the launcher placed the widget at (portrait), or a default before it has reported one. */
private fun placedSize(kind: WidgetKind): DpSize {
val options = AppWidgetManager.getInstance(context)?.getAppWidgetOptions(appWidgetId)
val width = options?.getInt(AppWidgetManager.OPTION_APPWIDGET_MIN_WIDTH) ?: 0
val height = options?.getInt(AppWidgetManager.OPTION_APPWIDGET_MAX_HEIGHT) ?: 0
if (width > 0 && height > 0) return DpSize(width.dp, height.dp)
return when (kind) {
WidgetKind.AGENDA -> DpSize(260.dp, 280.dp)
WidgetKind.MONTH -> DpSize(320.dp, 340.dp)
}
}
fun setAppearance(appearance: WidgetAppearance) = edit { it.copy(appearance = appearance) }
fun setAgendaOptions(options: AgendaWidgetOptions) {
edit { it.copy(agenda = options) }
reloadPreview()
}
private fun edit(change: (WidgetConfigUiState.Ready) -> WidgetConfigUiState.Ready) {
_state.update { if (it is WidgetConfigUiState.Ready) change(it) else it }
}
private fun reloadPreview() {
val ready = _state.value as? WidgetConfigUiState.Ready ?: return
previewJob?.cancel()
previewJob = viewModelScope.launch {
val preview = try {
loadPreview(ready)
} catch (e: CancellationException) {
throw e
} catch (_: Exception) {
// The options stay editable; the preview keeps what it had.
edit { it.copy(previewFailed = it.preview == null) }
return@launch
}
edit { it.copy(preview = preview, previewFailed = false) }
}
}
private suspend fun loadPreview(ready: WidgetConfigUiState.Ready): WidgetPreviewData = when (ready.kind) {
WidgetKind.AGENDA -> {
val settings = prefs.widgetSettings.first()
WidgetPreviewData.Agenda(context.loadAgendaWidgetData(settings, requireNotNull(ready.agenda)))
}
WidgetKind.MONTH -> {
val index = monthIndexOf(today(systemZone()))
WidgetPreviewData.Month(context.loadThemedMonthSource(monthWindowCenter(index)).content, index)
}
}
/** Writes the draft into the widget and redraws it, then reports how that went on [events]. */
fun save() {
val ready = _state.value as? WidgetConfigUiState.Ready ?: return
if (ready.saving) return
edit { it.copy(saving = true) }
viewModelScope.launch {
try {
val glanceId = GlanceAppWidgetManager(context).getGlanceIdBy(appWidgetId)
ready.kind.widget().updateWithRefresh(context, glanceId) { state ->
state.putAppearance(ready.appearance)
ready.agenda?.let { state.putAgendaOptions(it) }
}
_events.send(WidgetConfigEvent.SAVED)
} catch (e: CancellationException) {
throw e
} catch (_: IllegalArgumentException) {
_state.value = WidgetConfigUiState.Missing
} catch (_: Exception) {
edit { it.copy(saving = false) }
_events.send(WidgetConfigEvent.SAVE_FAILED)
}
}
}
}
@@ -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<T : Any>(val item: T?, val cols: Int)
/**
* The seven columns as cells and the gaps between them, consecutive empty
* columns merged into one: the day-open taps come from the strip underneath,
* so a gap needs no per-column view. [cellAt] gives what starts at a column
* and how many columns it covers.
*/
internal fun <T : Any> mergeGaps(columns: Int, cellAt: (Int) -> GridCell<T>?): List<GridCell<T>> {
val cells = mutableListOf<GridCell<T>>()
var gap = 0
var col = 0
while (col < columns) {
val cell = cellAt(col)
if (cell == null) {
gap += 1
col += 1
continue
}
if (gap > 0) cells += GridCell(null, gap)
gap = 0
cells += cell
col += cell.cols
}
if (gap > 0) cells += GridCell(null, gap)
return cells
}
/**
* One week of the grid seated with [laneCap] lanes. [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<DaySeating> = week.days.indices.map { week.seating(it, laneCap) }
/** Lanes filled from the top; empty lanes below are not drawn. */
val usedLanes: Int = seating.maxOfOrNull { day -> day.seated.maxOfOrNull { it.lane + 1 } ?: 0 } ?: 0
/** Each drawn lane as bars and merged gaps. */
val lanes: List<List<GridCell<EventInstance>>> = List(usedLanes) { lane ->
mergeGaps(week.days.size) { col ->
val seat = seating[col].seated.firstOrNull { it.lane == lane } ?: return@mergeGaps null
val endCol = if (seat.bar) week.spanAt(col, lane)?.endCol ?: col else col
GridCell(seat.event, endCol - col + 1)
}
}
/** The "+N" row as counts and merged gaps; empty when everything fits. */
val overflow: List<GridCell<Int>> = seating.map { it.hidden.size }.let { hidden ->
if (hidden.none { it > 0 }) emptyList()
else mergeGaps(hidden.size) { col -> hidden[col].takeIf { it > 0 }?.let { GridCell(it, 1) } }
}
}
/**
* 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<MonthWeek>, lanes: Int): List<WeekLayout> = 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
@@ -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<WeekLayout>,
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,
)
@@ -1,47 +1,43 @@
package de.jeanlucmakiola.calendula.widget.month
import de.jeanlucmakiola.calendula.domain.isDeclined
import de.jeanlucmakiola.calendula.widget.glanceDeclinedDecoration
import android.content.Context
import android.content.res.Configuration
import androidx.compose.runtime.Composable
import androidx.compose.runtime.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
@@ -49,28 +45,37 @@ 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 +88,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 +108,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 +121,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 +131,55 @@ 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 suspend fun provideGlance(context: Context, id: GlanceId) {
val shown = getAppWidgetState<Preferences>(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<Preferences>().widgetAppearance,
appTheme = loaded.appTheme,
)
}
}
}
/** The whole month widget showing month [index], shared with its config screen's preview (#292). */
@Composable
internal fun MonthWidgetContent(source: MonthWidgetSource, index: Int, appearance: WidgetAppearance, appTheme: ThemeMode) {
CalendulaWidgetRoot(appearance, appTheme) {
MonthWidgetBody(source = source, index = index)
}
}
/**
* Step the displayed month by the `delta` action parameter (±1).
*
@@ -179,57 +216,55 @@ 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 context = LocalContext.current
var loaded by remember(source) { mutableStateOf<MonthWidgetSource.Ready>(source) }
LaunchedEffect(index) {
if (index !in loaded.months) {
(context.loadMonthWidgetSource(index) as? MonthWidgetSource.Ready)
?.let { loaded = it }
}
}
// Column width from the live widget size, minus our H padding.
val colW = (LocalSize.current.width - GRID_HPADDING * 2) / 7
val weeks = layoutMonthWeeks(ym, loaded.weekStart, loaded.instances, zone)
val weeks = layoutMonthWeeks(ym, source.weekStart, source.instances, zone)
val context = LocalContext.current
val fontScale = context.resources.configuration.fontScale
val weekHeight = (LocalSize.current.height - gridChromeHeight(fontScale)) / weeks.size
val layouts = fitWeeks(weeks, lanesFitting(weekHeight))
val mode = LocalWidgetThemeMode.current
val colors = GridColors(
onSurface = 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, colW = colW)
AndroidRemoteViews(
remoteViews = monthGridViews(context, layouts, ym.month, source.today, source.soften, colors, mode),
modifier = GlanceModifier.fillMaxWidth().defaultWeight(),
)
}
}
@@ -238,89 +273,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<ShiftMonthAction>(
actionParametersOf(ShiftMonthAction.deltaKey to -1),
),
),
action = actionRunCallback<ShiftMonthAction>(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<ResetMonthAction>()),
action = actionRunCallback<ResetMonthAction>(),
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<ShiftMonthAction>(
actionParametersOf(ShiftMonthAction.deltaKey to 1),
),
),
action = actionRunCallback<ShiftMonthAction>(actionParametersOf(ShiftMonthAction.deltaKey to 1)),
style = WidgetActionStyle.TONAL,
)
}
}
@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),
WidgetHeaderActionGap()
WidgetHeaderAction(
icon = ImageProvider(R.drawable.ic_widget_add),
contentDescription = context.getString(R.string.widget_new_event),
action = actionStartActivity(MainActivity.openCreateIntent(context, today)),
)
}
}
@Composable
private fun WeekdayHeader(weekStart: DayOfWeek, colW: Dp) {
Row(modifier = GlanceModifier.fillMaxWidth()) {
Row(modifier = GlanceModifier.fillMaxWidth().padding(bottom = 1.dp)) {
weekdayNarrowNames(weekStart).forEach { name ->
Text(
text = name,
style = TextStyle(
color = GlanceTheme.colors.onSurfaceVariant,
fontSize = 11.sp,
textAlign = TextAlign.Center,
),
modifier = GlanceModifier.width(colW),
)
Box(modifier = GlanceModifier.width(colW).padding(horizontal = 1.dp)) {
Text(
text = name,
style = TextStyle(
color = GlanceTheme.colors.onPrimaryContainer,
fontSize = 11.sp,
textAlign = TextAlign.Center,
),
modifier = GlanceModifier
.fillMaxWidth()
.cornerRadius(8.dp)
.background(GlanceTheme.colors.primaryContainer)
.padding(vertical = 1.dp),
)
}
}
}
}
/** The fill of a day cell: one tone off a filled root, the widget surface over a wallpaper. */
private fun monthCellRole(background: WidgetBackground): WidgetColorRole = when (background) {
WidgetBackground.SOLID -> WidgetColorRole.SURFACE_VARIANT
WidgetBackground.TINTED, WidgetBackground.TRANSPARENT -> WidgetColorRole.WIDGET_BACKGROUND
}
@Composable
private fun monthCellRole(): WidgetColorRole = monthCellRole(LocalWidgetBackground.current)
/** Narrow weekday initials starting at [weekStart], in the device locale.
* Computed outside the composable so the locale read stays observable-safe. */
private fun weekdayNarrowNames(weekStart: DayOfWeek): List<String> {
@@ -331,266 +356,6 @@ private fun weekdayNarrowNames(weekStart: DayOfWeek): List<String> {
}
}
@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<Int> = days.mapIndexed { col, date ->
val shownSpans = spans.count { col in it.startCol..it.endCol && it.lane < MAX_LANES }
val freeSlots = (MAX_LANES - shownSpans).coerceAtLeast(0)
val timedShown = minOf(freeSlots, timedByDay[date].orEmpty().size)
((countByDay[date] ?: 0) - shownSpans - timedShown).coerceAtLeast(0)
}
/**
* Open [date]'s day view rooted in the month view (so back returns to the month
* 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<LaneCell> {
val cells = mutableListOf<LaneCell>()
var gap = 0
var col = 0
fun closeGap() {
if (gap > 0) cells += LaneCell(null, gap)
gap = 0
}
while (col < 7) {
val span = spans.firstOrNull { it.lane == lane && col in it.startCol..it.endCol }
val timed = if (span == null) timedEventAt(this, lane, col, days[col]) else null
when {
span != null -> {
closeGap()
cells += LaneCell(span.event, span.endCol - col + 1)
col = span.endCol + 1
}
timed != null -> {
closeGap()
cells += LaneCell(timed, 1)
col += 1
}
else -> {
gap += 1
col += 1
}
}
}
closeGap()
return cells
}
@Composable
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<Int>, 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 +370,7 @@ private fun PermissionMessage() {
fontSize = 14.sp,
textAlign = TextAlign.Center,
),
modifier = GlanceModifier.wallpaperBacking().padding(horizontal = 12.dp, vertical = 8.dp),
)
}
}
@@ -1,10 +1,13 @@
package de.jeanlucmakiola.calendula.widget.month
import android.annotation.SuppressLint
import android.appwidget.AppWidgetManager
import android.content.Context
import androidx.glance.appwidget.GlanceAppWidget
import androidx.glance.appwidget.GlanceAppWidgetReceiver
import de.jeanlucmakiola.calendula.widget.WidgetRolloverScheduler
import de.jeanlucmakiola.calendula.widget.syncWidgetRollover
import de.jeanlucmakiola.floret.glance.reloadOnUpdate
import de.jeanlucmakiola.floret.glance.restoreState
/**
* Host-facing receiver for the month widget. Declared in the manifest with the
@@ -16,16 +19,21 @@ class MonthWidgetReceiver : GlanceAppWidgetReceiver() {
/** First month widget placed — start rolling "today" over at midnight (#228). */
override fun onEnabled(context: Context) {
super.onEnabled(context)
WidgetRolloverScheduler.sync(context)
syncWidgetRollover(context)
}
/**
* Last month widget removed. [WidgetRolloverScheduler.sync] cancels only if
* Last month widget removed. [syncWidgetRollover] cancels only if
* no agenda widget is left either.
*/
override fun onDisabled(context: Context) {
super.onDisabled(context)
WidgetRolloverScheduler.sync(context)
syncWidgetRollover(context)
}
/** A backup restore or device transfer renumbered the widgets: keep each one's settings (#292). */
override fun onRestored(context: Context, oldWidgetIds: IntArray, newWidgetIds: IntArray) {
restoreState(context, oldWidgetIds, newWidgetIds)
}
/**
@@ -35,12 +43,14 @@ class MonthWidgetReceiver : GlanceAppWidgetReceiver() {
* freeze) is repaired here rather than on the next app open. Re-arming
* closer to midnight also narrows the inexact delivery window.
*/
@SuppressLint("MissingSuperCall")
override fun onUpdate(
context: Context,
appWidgetManager: AppWidgetManager,
appWidgetIds: IntArray,
) {
super.onUpdate(context, appWidgetManager, appWidgetIds)
WidgetRolloverScheduler.sync(context)
// In place of super, which only recomposes and would take the goAsync handle.
reloadOnUpdate(context, appWidgetIds)
syncWidgetRollover(context)
}
}
@@ -1,6 +0,0 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp" android:height="24dp"
android:viewportWidth="24" android:viewportHeight="24">
<path android:fillColor="@android:color/white"
android:pathData="M17.65,6.35C16.2,4.9 14.21,4 12,4c-4.42,0 -7.99,3.58 -7.99,8s3.57,8 7.99,8c3.73,0 6.84,-2.55 7.73,-6h-2.08c-0.82,2.33 -3.04,4 -5.65,4 -3.31,0 -6,-2.69 -6,-6s2.69,-6 6,-6c1.66,0 3.14,0.69 4.22,1.78L13,11h7V4l-2.35,2.35z"/>
</vector>
@@ -1,6 +0,0 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp" android:height="24dp"
android:viewportWidth="24" android:viewportHeight="24">
<path android:fillColor="@android:color/white"
android:pathData="M19,3h-1V1h-2v2H8V1H6v2H5C3.89,3 3.01,3.9 3.01,5L3,19c0,1.1 0.89,2 2,2h14c1.1,0 2,-0.9 2,-2V5c0,-1.1 -0.9,-2 -2,-2zM19,19H5V8h14v11zM7,10h5v5H7z"/>
</vector>
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android" android:shape="rectangle">
<corners android:radius="4dp" />
<solid android:color="@android:color/white" />
</shape>
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android" android:shape="rectangle">
<corners android:radius="8dp" />
<solid android:color="@android:color/white" />
</shape>
@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android" android:shape="oval">
<solid android:color="@android:color/white" />
</shape>
@@ -0,0 +1,26 @@
<?xml version="1.0" encoding="utf-8"?>
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="0dp"
android:layout_height="match_parent"
android:layout_weight="1"
android:paddingHorizontal="1dp">
<ImageView
android:id="@+id/bar_bg"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:importantForAccessibility="no"
android:scaleType="fitXY"
android:src="@drawable/widget_month_bar_bg" />
<TextView
android:id="@+id/bar_title"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:ellipsize="end"
android:gravity="center_vertical"
android:includeFontPadding="false"
android:maxLines="1"
android:paddingHorizontal="3dp"
android:textSize="9sp" />
</FrameLayout>
@@ -0,0 +1,26 @@
<?xml version="1.0" encoding="utf-8"?>
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="0dp"
android:layout_height="match_parent"
android:layout_weight="2"
android:paddingHorizontal="1dp">
<ImageView
android:id="@+id/bar_bg"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:importantForAccessibility="no"
android:scaleType="fitXY"
android:src="@drawable/widget_month_bar_bg" />
<TextView
android:id="@+id/bar_title"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:ellipsize="end"
android:gravity="center_vertical"
android:includeFontPadding="false"
android:maxLines="1"
android:paddingHorizontal="3dp"
android:textSize="9sp" />
</FrameLayout>
@@ -0,0 +1,26 @@
<?xml version="1.0" encoding="utf-8"?>
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="0dp"
android:layout_height="match_parent"
android:layout_weight="3"
android:paddingHorizontal="1dp">
<ImageView
android:id="@+id/bar_bg"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:importantForAccessibility="no"
android:scaleType="fitXY"
android:src="@drawable/widget_month_bar_bg" />
<TextView
android:id="@+id/bar_title"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:ellipsize="end"
android:gravity="center_vertical"
android:includeFontPadding="false"
android:maxLines="1"
android:paddingHorizontal="3dp"
android:textSize="9sp" />
</FrameLayout>
@@ -0,0 +1,26 @@
<?xml version="1.0" encoding="utf-8"?>
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="0dp"
android:layout_height="match_parent"
android:layout_weight="4"
android:paddingHorizontal="1dp">
<ImageView
android:id="@+id/bar_bg"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:importantForAccessibility="no"
android:scaleType="fitXY"
android:src="@drawable/widget_month_bar_bg" />
<TextView
android:id="@+id/bar_title"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:ellipsize="end"
android:gravity="center_vertical"
android:includeFontPadding="false"
android:maxLines="1"
android:paddingHorizontal="3dp"
android:textSize="9sp" />
</FrameLayout>
@@ -0,0 +1,26 @@
<?xml version="1.0" encoding="utf-8"?>
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="0dp"
android:layout_height="match_parent"
android:layout_weight="5"
android:paddingHorizontal="1dp">
<ImageView
android:id="@+id/bar_bg"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:importantForAccessibility="no"
android:scaleType="fitXY"
android:src="@drawable/widget_month_bar_bg" />
<TextView
android:id="@+id/bar_title"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:ellipsize="end"
android:gravity="center_vertical"
android:includeFontPadding="false"
android:maxLines="1"
android:paddingHorizontal="3dp"
android:textSize="9sp" />
</FrameLayout>
@@ -0,0 +1,26 @@
<?xml version="1.0" encoding="utf-8"?>
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="0dp"
android:layout_height="match_parent"
android:layout_weight="6"
android:paddingHorizontal="1dp">
<ImageView
android:id="@+id/bar_bg"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:importantForAccessibility="no"
android:scaleType="fitXY"
android:src="@drawable/widget_month_bar_bg" />
<TextView
android:id="@+id/bar_title"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:ellipsize="end"
android:gravity="center_vertical"
android:includeFontPadding="false"
android:maxLines="1"
android:paddingHorizontal="3dp"
android:textSize="9sp" />
</FrameLayout>
@@ -0,0 +1,26 @@
<?xml version="1.0" encoding="utf-8"?>
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="0dp"
android:layout_height="match_parent"
android:layout_weight="7"
android:paddingHorizontal="1dp">
<ImageView
android:id="@+id/bar_bg"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:importantForAccessibility="no"
android:scaleType="fitXY"
android:src="@drawable/widget_month_bar_bg" />
<TextView
android:id="@+id/bar_title"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:ellipsize="end"
android:gravity="center_vertical"
android:includeFontPadding="false"
android:maxLines="1"
android:paddingHorizontal="3dp"
android:textSize="9sp" />
</FrameLayout>
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="0dp"
android:layout_height="match_parent"
android:layout_weight="1" />
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="0dp"
android:layout_height="match_parent"
android:layout_weight="2" />
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="0dp"
android:layout_height="match_parent"
android:layout_weight="3" />
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="0dp"
android:layout_height="match_parent"
android:layout_weight="4" />
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="0dp"
android:layout_height="match_parent"
android:layout_weight="5" />
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="0dp"
android:layout_height="match_parent"
android:layout_weight="6" />
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="0dp"
android:layout_height="match_parent"
android:layout_weight="7" />
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:id="@+id/month_grid"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical" />
@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:id="@+id/lane"
android:layout_width="match_parent"
android:layout_height="15dp"
android:orientation="horizontal"
android:paddingBottom="1dp"
android:weightSum="7" />
@@ -0,0 +1,11 @@
<?xml version="1.0" encoding="utf-8"?>
<TextView xmlns:android="http://schemas.android.com/apk/res/android"
android:id="@+id/more"
android:layout_width="0dp"
android:layout_height="match_parent"
android:layout_weight="1"
android:gravity="center_vertical"
android:includeFontPadding="false"
android:maxLines="1"
android:paddingStart="3dp"
android:textSize="9sp" />
@@ -0,0 +1,360 @@
<?xml version="1.0" encoding="utf-8"?>
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_weight="1">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="horizontal">
<FrameLayout
android:id="@+id/strip_0"
android:layout_width="0dp"
android:layout_height="match_parent"
android:layout_weight="1"
android:foreground="?android:attr/selectableItemBackground"
android:padding="1dp">
<ImageView
android:id="@+id/cell_0"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:importantForAccessibility="no"
android:scaleType="fitXY"
android:src="@drawable/widget_month_cell_bg" />
<ImageView
android:id="@+id/cell_dim_0"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:importantForAccessibility="no"
android:scaleType="fitXY"
android:src="@drawable/widget_month_cell_bg"
android:visibility="gone" />
</FrameLayout>
<FrameLayout
android:id="@+id/strip_1"
android:layout_width="0dp"
android:layout_height="match_parent"
android:layout_weight="1"
android:foreground="?android:attr/selectableItemBackground"
android:padding="1dp">
<ImageView
android:id="@+id/cell_1"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:importantForAccessibility="no"
android:scaleType="fitXY"
android:src="@drawable/widget_month_cell_bg" />
<ImageView
android:id="@+id/cell_dim_1"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:importantForAccessibility="no"
android:scaleType="fitXY"
android:src="@drawable/widget_month_cell_bg"
android:visibility="gone" />
</FrameLayout>
<FrameLayout
android:id="@+id/strip_2"
android:layout_width="0dp"
android:layout_height="match_parent"
android:layout_weight="1"
android:foreground="?android:attr/selectableItemBackground"
android:padding="1dp">
<ImageView
android:id="@+id/cell_2"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:importantForAccessibility="no"
android:scaleType="fitXY"
android:src="@drawable/widget_month_cell_bg" />
<ImageView
android:id="@+id/cell_dim_2"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:importantForAccessibility="no"
android:scaleType="fitXY"
android:src="@drawable/widget_month_cell_bg"
android:visibility="gone" />
</FrameLayout>
<FrameLayout
android:id="@+id/strip_3"
android:layout_width="0dp"
android:layout_height="match_parent"
android:layout_weight="1"
android:foreground="?android:attr/selectableItemBackground"
android:padding="1dp">
<ImageView
android:id="@+id/cell_3"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:importantForAccessibility="no"
android:scaleType="fitXY"
android:src="@drawable/widget_month_cell_bg" />
<ImageView
android:id="@+id/cell_dim_3"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:importantForAccessibility="no"
android:scaleType="fitXY"
android:src="@drawable/widget_month_cell_bg"
android:visibility="gone" />
</FrameLayout>
<FrameLayout
android:id="@+id/strip_4"
android:layout_width="0dp"
android:layout_height="match_parent"
android:layout_weight="1"
android:foreground="?android:attr/selectableItemBackground"
android:padding="1dp">
<ImageView
android:id="@+id/cell_4"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:importantForAccessibility="no"
android:scaleType="fitXY"
android:src="@drawable/widget_month_cell_bg" />
<ImageView
android:id="@+id/cell_dim_4"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:importantForAccessibility="no"
android:scaleType="fitXY"
android:src="@drawable/widget_month_cell_bg"
android:visibility="gone" />
</FrameLayout>
<FrameLayout
android:id="@+id/strip_5"
android:layout_width="0dp"
android:layout_height="match_parent"
android:layout_weight="1"
android:foreground="?android:attr/selectableItemBackground"
android:padding="1dp">
<ImageView
android:id="@+id/cell_5"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:importantForAccessibility="no"
android:scaleType="fitXY"
android:src="@drawable/widget_month_cell_bg" />
<ImageView
android:id="@+id/cell_dim_5"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:importantForAccessibility="no"
android:scaleType="fitXY"
android:src="@drawable/widget_month_cell_bg"
android:visibility="gone" />
</FrameLayout>
<FrameLayout
android:id="@+id/strip_6"
android:layout_width="0dp"
android:layout_height="match_parent"
android:layout_weight="1"
android:foreground="?android:attr/selectableItemBackground"
android:padding="1dp">
<ImageView
android:id="@+id/cell_6"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:importantForAccessibility="no"
android:scaleType="fitXY"
android:src="@drawable/widget_month_cell_bg" />
<ImageView
android:id="@+id/cell_dim_6"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:importantForAccessibility="no"
android:scaleType="fitXY"
android:src="@drawable/widget_month_cell_bg"
android:visibility="gone" />
</FrameLayout>
</LinearLayout>
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="18dp"
android:orientation="horizontal">
<FrameLayout
android:layout_width="0dp"
android:layout_height="match_parent"
android:layout_weight="1">
<ImageView
android:id="@+id/today_bg_0"
android:layout_width="18dp"
android:layout_height="18dp"
android:layout_gravity="center"
android:importantForAccessibility="no"
android:src="@drawable/widget_month_today_bg"
android:visibility="gone" />
<TextView
android:id="@+id/day_0"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:gravity="center"
android:includeFontPadding="false"
android:textSize="11sp" />
</FrameLayout>
<FrameLayout
android:layout_width="0dp"
android:layout_height="match_parent"
android:layout_weight="1">
<ImageView
android:id="@+id/today_bg_1"
android:layout_width="18dp"
android:layout_height="18dp"
android:layout_gravity="center"
android:importantForAccessibility="no"
android:src="@drawable/widget_month_today_bg"
android:visibility="gone" />
<TextView
android:id="@+id/day_1"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:gravity="center"
android:includeFontPadding="false"
android:textSize="11sp" />
</FrameLayout>
<FrameLayout
android:layout_width="0dp"
android:layout_height="match_parent"
android:layout_weight="1">
<ImageView
android:id="@+id/today_bg_2"
android:layout_width="18dp"
android:layout_height="18dp"
android:layout_gravity="center"
android:importantForAccessibility="no"
android:src="@drawable/widget_month_today_bg"
android:visibility="gone" />
<TextView
android:id="@+id/day_2"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:gravity="center"
android:includeFontPadding="false"
android:textSize="11sp" />
</FrameLayout>
<FrameLayout
android:layout_width="0dp"
android:layout_height="match_parent"
android:layout_weight="1">
<ImageView
android:id="@+id/today_bg_3"
android:layout_width="18dp"
android:layout_height="18dp"
android:layout_gravity="center"
android:importantForAccessibility="no"
android:src="@drawable/widget_month_today_bg"
android:visibility="gone" />
<TextView
android:id="@+id/day_3"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:gravity="center"
android:includeFontPadding="false"
android:textSize="11sp" />
</FrameLayout>
<FrameLayout
android:layout_width="0dp"
android:layout_height="match_parent"
android:layout_weight="1">
<ImageView
android:id="@+id/today_bg_4"
android:layout_width="18dp"
android:layout_height="18dp"
android:layout_gravity="center"
android:importantForAccessibility="no"
android:src="@drawable/widget_month_today_bg"
android:visibility="gone" />
<TextView
android:id="@+id/day_4"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:gravity="center"
android:includeFontPadding="false"
android:textSize="11sp" />
</FrameLayout>
<FrameLayout
android:layout_width="0dp"
android:layout_height="match_parent"
android:layout_weight="1">
<ImageView
android:id="@+id/today_bg_5"
android:layout_width="18dp"
android:layout_height="18dp"
android:layout_gravity="center"
android:importantForAccessibility="no"
android:src="@drawable/widget_month_today_bg"
android:visibility="gone" />
<TextView
android:id="@+id/day_5"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:gravity="center"
android:includeFontPadding="false"
android:textSize="11sp" />
</FrameLayout>
<FrameLayout
android:layout_width="0dp"
android:layout_height="match_parent"
android:layout_weight="1">
<ImageView
android:id="@+id/today_bg_6"
android:layout_width="18dp"
android:layout_height="18dp"
android:layout_gravity="center"
android:importantForAccessibility="no"
android:src="@drawable/widget_month_today_bg"
android:visibility="gone" />
<TextView
android:id="@+id/day_6"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:gravity="center"
android:includeFontPadding="false"
android:textSize="11sp" />
</FrameLayout>
</LinearLayout>
<LinearLayout
android:id="@+id/week_lanes"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:paddingTop="2dp" />
</LinearLayout>
</FrameLayout>
+21 -3
View File
@@ -441,6 +441,21 @@
<string name="widget_prev_month">Previous month</string>
<string name="widget_next_month">Next month</string>
<string name="widget_today">Today</string>
<string name="widget_config_background">Background</string>
<string name="widget_config_background_solid">Solid</string>
<string name="widget_config_background_tinted">Tinted</string>
<string name="widget_config_background_transparent">Transparent</string>
<string name="widget_config_theme_follow_app">Follow app</string>
<string name="widget_config_content">Content</string>
<string name="widget_config_range">Time range</string>
<string name="widget_config_text_size">Text size</string>
<string name="widget_config_load_failed">Couldn\'t read this widget\'s settings.</string>
<string name="widget_config_preview_failed">Preview unavailable</string>
<string name="widget_config_save_failed">Couldn\'t save the widget. Try again.</string>
<!-- A widget's name and its number, when there are several of the same kind: "Calendula agenda 2" -->
<string name="widget_numbered">%1$s %2$d</string>
<!-- A placed widget's background and theme: "Tinted · Follow app" -->
<string name="widget_appearance_summary">%1$s · %2$s</string>
<!-- Calendar filter (M3) -->
<string name="filter_title">Calendars</string>
@@ -554,7 +569,7 @@
<!-- %1$d is the event text size as a percentage of the smallest step, e.g. 130. -->
<string name="settings_widget_size_summary">Event text %1$d%%</string>
<string name="settings_agenda_show_today">Always show today</string>
<string name="settings_agenda_show_today_hint">Keep today at the top of the agenda and its widget, even once nothing is left today.</string>
<string name="settings_agenda_show_today_hint">Keep today at the top of the agenda, even once nothing is left today. Each widget has its own setting.</string>
<string name="settings_agenda_range_bar">Range bar</string>
<string name="settings_agenda_range_bar_hint">Show a bar at the top of the agenda naming the dates shown, with a button to switch the range for the session</string>
<string name="agenda_range_day">Today</string>
@@ -645,12 +660,15 @@
<string name="settings_event_form_subtitle">Default fields and behaviour</string>
<string name="settings_notifications_subtitle">Reminders and delivery</string>
<string name="settings_special_dates_subtitle">Contact birthdays &amp; anniversaries</string>
<string name="settings_widgets_subtitle">Agenda widget, Quick Settings tile</string>
<string name="settings_widgets_subtitle">Your widgets, Quick Settings tile</string>
<string name="settings_backup_subtitle">Export, import, automatic backup</string>
<!-- Widgets &amp; tiles (#69) -->
<string name="settings_section_widgets">Widgets &amp; tiles</string>
<string name="settings_widgets_hint">Settings for the home-screen widgets and the Quick Settings tile. Add a widget by long-pressing your home screen.</string>
<string name="settings_widgets_hint">Each widget has its own look and options. Tap one below, or long-press it on your home screen and choose Edit. Add a widget by long-pressing your home screen.</string>
<string name="settings_widgets_hint_legacy">Each widget has its own look and options. Tap one below to change it. Add a widget by long-pressing your home screen.</string>
<string name="settings_widgets_placed">On your home screen</string>
<string name="settings_widgets_none">No Calendula widgets on your home screen yet.</string>
<!-- Backup &amp; restore (#69) — the rows themselves reuse the calendars_* strings -->
<string name="settings_section_backup">Backup &amp; restore</string>
+6
View File
@@ -7,4 +7,10 @@
<item name="android:windowBackground">@color/window_background</item>
<item name="android:windowLightStatusBar">true</item>
</style>
<!-- Widget config: drawn over the wallpaper, so the preview shows the real home screen. -->
<style name="Theme.Calendula.WidgetConfig">
<item name="android:windowBackground">@android:color/transparent</item>
<item name="android:windowShowWallpaper">true</item>
</style>
</resources>
@@ -8,6 +8,8 @@
android:minResizeHeight="110dp"
android:resizeMode="horizontal|vertical"
android:widgetCategory="home_screen"
android:configure="de.jeanlucmakiola.calendula.widget.config.WidgetConfigActivity"
android:widgetFeatures="reconfigurable"
android:description="@string/widget_agenda_label"
android:updatePeriodMillis="1800000"
android:previewLayout="@layout/widget_preview_agenda"
@@ -8,6 +8,8 @@
android:minResizeHeight="150dp"
android:resizeMode="horizontal|vertical"
android:widgetCategory="home_screen"
android:configure="de.jeanlucmakiola.calendula.widget.config.WidgetConfigActivity"
android:widgetFeatures="reconfigurable"
android:description="@string/widget_month_label"
android:updatePeriodMillis="1800000"
android:previewLayout="@layout/widget_preview_month"
@@ -165,15 +165,12 @@ class SettingsPrefsTest {
}
@Test
fun `agenda ranges default to month and round-trip independently`(@TempDir tempDir: Path) = runTest {
fun `agenda screen range defaults to month and round-trips`(@TempDir tempDir: Path) = runTest {
val prefs = testSettings(tempDir)
assertThat(prefs.agendaScreenRange.first()).isEqualTo(AgendaRange.Month)
assertThat(prefs.agendaWidgetRange.first()).isEqualTo(AgendaRange.Month)
prefs.setAgendaScreenRange(AgendaRange.Custom(14))
prefs.setAgendaWidgetRange(AgendaRange.Week)
assertThat(prefs.agendaScreenRange.first()).isEqualTo(AgendaRange.Custom(14))
assertThat(prefs.agendaWidgetRange.first()).isEqualTo(AgendaRange.Week)
}
@Test
@@ -0,0 +1,23 @@
package de.jeanlucmakiola.calendula.widget
import com.google.common.truth.Truth.assertThat
import org.junit.jupiter.api.Test
class MonthWindowCenterTest {
@Test
fun `the loaded window always reaches two months past the shown one`() {
for (index in -40..40) {
val center = monthWindowCenter(index)
assertThat(index - 2).isAtLeast(center - MONTH_WIDGET_PAD_MONTHS)
assertThat(index + 2).isAtMost(center + MONTH_WIDGET_PAD_MONTHS)
}
}
@Test
fun `paging within a step of three keeps the same window`() {
val year = 2026 * 12
assertThat(monthWindowCenter(year)).isEqualTo(monthWindowCenter(year + 2))
assertThat(monthWindowCenter(year + 3)).isNotEqualTo(monthWindowCenter(year + 2))
}
}
@@ -1,189 +0,0 @@
package de.jeanlucmakiola.calendula.widget
import android.content.Intent
import com.google.common.truth.Truth.assertThat
import kotlinx.datetime.LocalDate
import kotlinx.datetime.LocalDateTime
import kotlinx.datetime.TimeZone
import kotlinx.datetime.atStartOfDayIn
import kotlinx.datetime.toInstant
import kotlinx.datetime.toLocalDateTime
import org.junit.jupiter.api.Test
import kotlin.time.Duration
import kotlin.time.Duration.Companion.hours
import kotlin.time.Duration.Companion.minutes
import kotlin.time.Instant
/**
* The rollover alarm is what makes the widgets stop highlighting yesterday
* (#228), so the "when is the next local midnight" arithmetic is the one piece
* worth pinning down — especially where midnight is not 00:00.
*/
class WidgetRolloverSchedulerTest {
private val berlin = TimeZone.of("Europe/Berlin")
private fun at(local: String, zone: TimeZone): Instant =
LocalDateTime.parse(local).toInstant(zone)
private fun nextRollover(local: String, zone: TimeZone = berlin): Instant =
WidgetRolloverScheduler.nextRolloverAt(at(local, zone), zone)
// --- the ordinary day ----------------------------------------------------
@Test
fun `midday rolls over at the coming midnight`() {
val next = nextRollover("2026-08-27T12:00:00")
assertThat(next).isEqualTo(at("2026-08-28T00:00:05", berlin))
}
@Test
fun `a second before midnight still targets tonight, not tomorrow night`() {
val next = nextRollover("2026-08-27T23:59:59")
assertThat(next).isEqualTo(at("2026-08-28T00:00:05", berlin))
}
@Test
fun `at midnight exactly the target is the next day, never the current instant`() {
// Re-arming after a firing must move a whole day on, or the widget wakes
// itself in a tight loop.
val now = at("2026-08-28T00:00:00", berlin)
val next = WidgetRolloverScheduler.nextRolloverAt(now, berlin)
assertThat(next).isEqualTo(at("2026-08-29T00:00:05", berlin))
assertThat(next - now).isGreaterThan(Duration.ZERO)
}
@Test
fun `re-arming from the slack instant itself moves a full day on`() {
// What actually happens in practice: the receiver runs at midnight + slack.
val now = at("2026-08-28T00:00:05", berlin)
assertThat(WidgetRolloverScheduler.nextRolloverAt(now, berlin))
.isEqualTo(at("2026-08-29T00:00:05", berlin))
}
@Test
fun `the result is always in the future for every minute of a day`() {
val zone = berlin
// Spans Berlin's 2024 spring-forward: the likeliest source of a target
// in the past, i.e. an alarm that fires immediately, forever.
var probe = LocalDateTime.parse("2024-03-29T00:00:00").toInstant(zone)
val end = LocalDateTime.parse("2024-04-01T00:00:00").toInstant(zone)
while (probe < end) {
assertThat(WidgetRolloverScheduler.nextRolloverAt(probe, zone)).isGreaterThan(probe)
probe += 1.minutes
}
}
// --- daylight saving -----------------------------------------------------
@Test
fun `spring forward keeps the rollover one day away, not one hour short`() {
// Berlin skipped 02:00-03:00 on 31 March 2024, so that day was 23h long.
// A rollover computed as "now + 24h" would land at 01:00 on 1 April.
val now = at("2024-03-30T12:00:00", berlin)
val next = WidgetRolloverScheduler.nextRolloverAt(now, berlin)
assertThat(next).isEqualTo(at("2024-03-31T00:00:05", berlin))
assertThat(next - now).isLessThan(24.hours)
}
@Test
fun `fall back does not overshoot into the repeated hour`() {
// Berlin repeated 02:00-03:00 on 27 October 2024: a 25h day, so
// "now + 24h" would land at 23:00 on the 26th and never roll over.
val now = at("2024-10-26T12:00:00", berlin)
val next = WidgetRolloverScheduler.nextRolloverAt(now, berlin)
assertThat(next).isEqualTo(at("2024-10-27T00:00:05", berlin))
assertThat(next.toLocalDateTime(berlin).date).isEqualTo(LocalDate.parse("2024-10-27"))
}
@Test
fun `a zone that repeats midnight takes the first start of day`() {
// Sao Paulo used to end DST by moving 00:00 back to 23:00, so the day
// began twice. Pinned as the accepted trade: the widget runs an hour
// ahead until the next redraw. No live zone does this since 2019.
val saoPaulo = TimeZone.of("America/Sao_Paulo")
val next = WidgetRolloverScheduler.nextRolloverAt(
at("2018-02-16T12:00:00", saoPaulo), saoPaulo,
)
val local = next.toLocalDateTime(saoPaulo)
assertThat(local.date).isEqualTo(LocalDate.parse("2018-02-17"))
assertThat(local.hour).isEqualTo(0)
}
@Test
fun `a zone where midnight does not exist rolls over at the real start of day`() {
// Cuba starts DST at 00:00, so 11 March 2018 began at 01:00 in Havana.
// Targeting a literal 00:00 there would arm an instant on the wrong day.
val havana = TimeZone.of("America/Havana")
val next = WidgetRolloverScheduler.nextRolloverAt(at("2018-03-10T12:00:00", havana), havana)
val local = next.toLocalDateTime(havana)
assertThat(local.date).isEqualTo(LocalDate.parse("2018-03-11"))
assertThat(local.hour).isEqualTo(1)
assertThat(local.minute).isEqualTo(0)
// And it is genuinely the first instant of that date, not a guess.
assertThat(next).isEqualTo(
LocalDate.parse("2018-03-11").atStartOfDayIn(havana) +
WidgetRolloverScheduler.ROLLOVER_SLACK,
)
}
// --- timezone changes ----------------------------------------------------
@Test
fun `the same instant rolls over at different times in different zones`() {
// TIMEZONE_CHANGED must re-arm to the new local midnight: the arithmetic
// follows the zone, not a cached offset.
val instant = at("2026-08-27T12:00:00", berlin)
val tokyo = TimeZone.of("Asia/Tokyo")
val berlinNext = WidgetRolloverScheduler.nextRolloverAt(instant, berlin)
val tokyoNext = WidgetRolloverScheduler.nextRolloverAt(instant, tokyo)
assertThat(tokyoNext).isNotEqualTo(berlinNext)
assertThat(tokyoNext).isLessThan(berlinNext)
assertThat(tokyoNext.toLocalDateTime(tokyo).hour).isEqualTo(0)
}
@Test
fun `a half-hour offset zone still lands on its own midnight`() {
val kathmandu = TimeZone.of("Asia/Kathmandu")
val next = WidgetRolloverScheduler.nextRolloverAt(
at("2026-08-27T12:00:00", kathmandu), kathmandu,
)
val local = next.toLocalDateTime(kathmandu)
assertThat(local.date.toString()).isEqualTo("2026-08-28")
assertThat(local.hour).isEqualTo(0)
}
// --- the wiring ----------------------------------------------------------
@Test
fun `the receiver actually handles the action the alarm is sent with`() {
// The single point where the whole fix would die silently: the alarm
// fires, the receiver drops it on the action guard, nothing redraws.
assertThat(WidgetUpdateReceiver.HANDLED_ACTIONS)
.contains(WidgetUpdateReceiver.ACTION_ROLLOVER)
}
@Test
fun `a data change is handled, and re-arms only on the throttle`() {
// PROVIDER_CHANGED fires on every calendar write — each event of a bulk
// import, each drop of a drag — so it is not an unconditional re-arm.
// It is not dropped either: the alarm is inexact and the system may lose
// it, and this is the only signal that keeps arriving to notice.
assertThat(WidgetUpdateReceiver.HANDLED_ACTIONS)
.containsAtLeast(Intent.ACTION_PROVIDER_CHANGED, Intent.ACTION_DATE_CHANGED)
assertThat(WidgetUpdateReceiver.REARM_ACTIONS)
.doesNotContain(Intent.ACTION_PROVIDER_CHANGED)
}
@Test
fun `everything that loses the alarm re-arms it`() {
assertThat(WidgetUpdateReceiver.REARM_ACTIONS).containsAtLeast(
WidgetUpdateReceiver.ACTION_ROLLOVER,
Intent.ACTION_BOOT_COMPLETED,
Intent.ACTION_MY_PACKAGE_REPLACED,
Intent.ACTION_TIME_CHANGED,
Intent.ACTION_TIMEZONE_CHANGED,
Intent.ACTION_DATE_CHANGED,
)
}
}
@@ -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)
}
@@ -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<EventInstance>): List<MonthWeek> =
layoutMonthWeeks(jul26, DayOfWeek.MONDAY, events, zone)
/** July 2026 with [perDay] timed events on every day of the grid. */
private fun busyWeeks(perDay: Int): List<MonthWeek> {
val first = LocalDate(2026, 6, 29)
var id = 0L
return weeks(
(0 until 42).flatMap { offset ->
val day = first.plus(offset, DateTimeUnit.DAY)
(1..perDay).map { hour -> timedEvent(day, hour, zone = zone, id = ++id, title = "E$id") }
},
)
}
@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()
}
}