diff --git a/.planning/ROADMAP.md b/.planning/ROADMAP.md
index f3206a5..b398bef 100644
--- a/.planning/ROADMAP.md
+++ b/.planning/ROADMAP.md
@@ -225,12 +225,12 @@ Out of scope (no new settings *features* here) — this is a structure + style
pass on the existing controls; new toggles ride in with their own features.
**Tier 2 — navigation & daily-driver completeness**
-5. Jump-to-date — drawer date picker (un-cut from V1); cheap, fills the nav gap *(next)*
-6. Agenda view — the missing 4th view; serves daily-driver users *and* becomes the data source for the widget
+5. ~~Jump-to-date — drawer date picker (un-cut from V1); cheap, fills the nav gap~~ *(done, v2.5.0)*
+6. ~~Agenda view — the missing 4th view; serves daily-driver users *and* becomes the data source for the widget~~ *(done, v2.5.0)*
**Tier 3 — platform reach (depends on Tier 2)**
-7. Home-screen widget — built on the agenda data source from #6
-8. App shortcuts (launcher long-press → New event); cheap, optional quick-settings tile
+7. ~~Home-screen widget — built on the agenda data source from #6~~ *(done, v2.5.0 — agenda + month widgets)*
+8. App shortcuts: ~~launcher long-press → New event~~ *(done, v2.5.0)*; optional quick-settings tile still open
**Tier 4 — interop & bigger-ticket**
9. Share event as .ics + receive/open .ics into a prefilled create form
diff --git a/.planning/STATE.md b/.planning/STATE.md
index 4cf5742..28e7971 100644
--- a/.planning/STATE.md
+++ b/.planning/STATE.md
@@ -8,8 +8,10 @@
v2.1.0 (month event grid, drawer view tabs, cursor fix) shipped 2026-06-15.
**Phase:** post-2.1 backlog work. v2.2.0 (tap-to-create in day/week + local
calendar management) and v2.3.0 (Material 3 grouped-list redesign of Settings,
-the calendar manager and the navigation drawer) both shipped 2026-06-16. The
-backlog is now organised by theme in `ROADMAP.md`.
+the calendar manager and the navigation drawer) both shipped 2026-06-16;
+v2.4.0 (per-event colors) and v2.5.0 (jump-to-date, Agenda view, home-screen
+agenda + month widgets, and a "New event" launcher shortcut) shipped
+2026-06-17. The backlog is now organised by theme in `ROADMAP.md`.
## Progress
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 0cc9624..f382c8d 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -7,6 +7,25 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
+## [2.5.0] — 2026-06-17
+
+### Added
+- Home-screen widgets (two of them): an "Upcoming" agenda widget — a scrolling
+ list of the next month of events grouped under day headers, with refresh and
+ "New event" buttons — and a month-grid widget showing the full month with
+ today highlighted, connected multi-day event bars, and prev/next/today
+ navigation. Both reuse the in-app grouping and layout so they match the app
+ exactly, respect your hidden-calendar choices, and refresh automatically when
+ the calendar changes or the day rolls over. Tapping a day opens that day;
+ tapping an event opens its details
+- App shortcut: long-press the Calendula icon for a "New event" action that
+ jumps straight into the create-event form
+- Agenda view — a fourth top-level view alongside Month/Week/Day: a
+ forward-looking list of upcoming events grouped under "Today"/"Tomorrow"/date
+ headers, reachable from the view switcher
+- Jump to date — a "Jump to date" row in the navigation drawer opens a date
+ picker and moves the active view (Month/Week/Day/Agenda) to the chosen day
+
## [2.4.0] — 2026-06-17
### Added
diff --git a/app/build.gradle.kts b/app/build.gradle.kts
index 674af39..8d1d8c1 100644
--- a/app/build.gradle.kts
+++ b/app/build.gradle.kts
@@ -28,8 +28,8 @@ android {
// the tag, with versionCode = MAJOR*10000 + MINOR*100 + PATCH
// (e.g. v2.0.0 -> 20000). These committed values are the dev/local
// default; keep them matching the latest released tag. See docs/RELEASING.md.
- versionCode = 20400
- versionName = "2.4.0"
+ versionCode = 20500
+ versionName = "2.5.0"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
}
@@ -113,6 +113,9 @@ dependencies {
implementation(libs.androidx.datastore.preferences)
+ implementation(libs.androidx.glance.appwidget)
+ implementation(libs.androidx.glance.material3)
+
implementation(libs.kotlinx.datetime)
implementation(libs.kotlinx.coroutines.core)
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index e26af49..7ca396d 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -38,6 +38,11 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
(null)
+ // A navigation a home-screen widget asked for (open a date / start a
+ // create). Consumed once by CalendarHost, same pattern as the detail key.
+ private var requestedNav by mutableStateOf(null)
+
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enableEdgeToEdge()
requestedDetailKey = intent.detailKeyOrNull()
+ requestedNav = intent.navRequestOrNull()
setContent {
// One activity-scoped SettingsViewModel drives both the theme here
// and the Settings screen, so a theme change applies app-wide at once.
@@ -51,6 +58,8 @@ class MainActivity : ComponentActivity() {
modifier = Modifier.fillMaxSize(),
requestedDetailKey = requestedDetailKey,
onDetailKeyConsumed = { requestedDetailKey = null },
+ widgetNavRequest = requestedNav,
+ onWidgetNavConsumed = { requestedNav = null },
)
}
}
@@ -59,6 +68,18 @@ class MainActivity : ComponentActivity() {
override fun onNewIntent(intent: Intent) {
super.onNewIntent(intent)
intent.detailKeyOrNull()?.let { requestedDetailKey = it }
+ intent.navRequestOrNull()?.let { requestedNav = it }
+ }
+
+ private fun Intent.navRequestOrNull(): WidgetNavRequest? = when {
+ // Launcher long-press "New event" shortcut. Static shortcut intents
+ // can't carry typed extras, so the action alone signals create-on-today.
+ action == ACTION_NEW_EVENT -> WidgetNavRequest.Create(null)
+ getBooleanExtra(EXTRA_CREATE, false) ->
+ WidgetNavRequest.Create(getStringExtra(EXTRA_DATE_ISO))
+ getStringExtra(EXTRA_DATE_ISO) != null ->
+ WidgetNavRequest.OpenDate(getStringExtra(EXTRA_DATE_ISO)!!)
+ else -> null
}
private fun Intent.detailKeyOrNull(): LongArray? {
@@ -75,6 +96,12 @@ class MainActivity : ComponentActivity() {
private const val EXTRA_EVENT_ID = "de.jeanlucmakiola.calendula.extra.EVENT_ID"
private const val EXTRA_BEGIN_MILLIS = "de.jeanlucmakiola.calendula.extra.BEGIN"
private const val EXTRA_END_MILLIS = "de.jeanlucmakiola.calendula.extra.END"
+ private const val EXTRA_DATE_ISO = "de.jeanlucmakiola.calendula.extra.DATE_ISO"
+ private const val EXTRA_CREATE = "de.jeanlucmakiola.calendula.extra.CREATE"
+
+ // Fired by the launcher long-press "New event" shortcut (res/xml/
+ // shortcuts.xml hardcodes this string — keep the two in sync).
+ const val ACTION_NEW_EVENT = "de.jeanlucmakiola.calendula.action.NEW_EVENT"
/**
* Intent opening the detail screen of one occurrence (reminder
@@ -93,5 +120,22 @@ class MainActivity : ComponentActivity() {
putExtra(EXTRA_END_MILLIS, endMillis)
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
}
+
+ /** Open the day view anchored on [date] (home-screen widgets). */
+ fun openDateIntent(context: Context, date: LocalDate): Intent =
+ Intent(context, MainActivity::class.java).apply {
+ data = "calendula://date/$date".toUri()
+ putExtra(EXTRA_DATE_ISO, date.toString())
+ addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
+ }
+
+ /** Open the create-event form prefilled for [date] (home-screen widgets). */
+ fun openCreateIntent(context: Context, date: LocalDate): Intent =
+ Intent(context, MainActivity::class.java).apply {
+ data = "calendula://create/$date".toUri()
+ putExtra(EXTRA_CREATE, true)
+ putExtra(EXTRA_DATE_ISO, date.toString())
+ addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
+ }
}
}
diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/CalendarHost.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/CalendarHost.kt
index 8536aba..92b6ffd 100644
--- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/CalendarHost.kt
+++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/CalendarHost.kt
@@ -27,6 +27,9 @@ import de.jeanlucmakiola.calendula.ui.month.MonthScreen
import de.jeanlucmakiola.calendula.ui.settings.SettingsScreen
import de.jeanlucmakiola.calendula.ui.week.WeekScreen
import kotlinx.datetime.LocalDate
+import kotlinx.datetime.TimeZone
+import kotlinx.datetime.toLocalDateTime
+import kotlin.time.Clock
/**
* Holds the active top-level view (spec M1) and swaps between the calendar
@@ -43,6 +46,8 @@ fun CalendarHost(
modifier: Modifier = Modifier,
requestedDetailKey: LongArray? = null,
onDetailKeyConsumed: () -> Unit = {},
+ widgetNavRequest: WidgetNavRequest? = null,
+ onWidgetNavConsumed: () -> Unit = {},
) {
var view by rememberSaveable { mutableStateOf(CalendarView.Week) }
val onSelectView: (CalendarView) -> Unit = { view = it }
@@ -116,6 +121,28 @@ fun CalendarHost(
var editKey by rememberSaveable { mutableStateOf(null) }
var heldEditKey by remember { mutableStateOf(null) }
+ // A home-screen widget launch asks to open a date (→ day view) or start a
+ // create. Handled once and cleared, mirroring [requestedDetailKey].
+ LaunchedEffect(widgetNavRequest) {
+ when (val req = widgetNavRequest) {
+ is WidgetNavRequest.OpenDate -> {
+ pendingDayIso = req.dateIso
+ view = CalendarView.Day
+ onWidgetNavConsumed()
+ }
+ is WidgetNavRequest.Create -> {
+ val iso = req.dateIso ?: Clock.System.now()
+ .toLocalDateTime(TimeZone.currentSystemDefault()).date.toString()
+ heldCreateIso = iso
+ createDateIso = iso
+ heldCreateMinutes = null
+ createStartMinutes = null
+ onWidgetNavConsumed()
+ }
+ null -> {}
+ }
+ }
+
val slideSpec = rememberCalendarSlideSpec()
Box(modifier = modifier.fillMaxSize()) {
diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/RootScreen.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/RootScreen.kt
index 72fe38e..9300645 100644
--- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/RootScreen.kt
+++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/RootScreen.kt
@@ -25,6 +25,8 @@ fun RootScreen(
modifier: Modifier = Modifier,
requestedDetailKey: LongArray? = null,
onDetailKeyConsumed: () -> Unit = {},
+ widgetNavRequest: WidgetNavRequest? = null,
+ onWidgetNavConsumed: () -> Unit = {},
) {
val context = LocalContext.current
var hasPermission by remember {
@@ -58,6 +60,8 @@ fun RootScreen(
modifier = modifier,
requestedDetailKey = requestedDetailKey,
onDetailKeyConsumed = onDetailKeyConsumed,
+ widgetNavRequest = widgetNavRequest,
+ onWidgetNavConsumed = onWidgetNavConsumed,
)
false -> ReminderOnboardingScreen(
onFinished = reminderOnboarding::finish,
diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/WidgetNavRequest.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/WidgetNavRequest.kt
new file mode 100644
index 0000000..2dcfe02
--- /dev/null
+++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/WidgetNavRequest.kt
@@ -0,0 +1,15 @@
+package de.jeanlucmakiola.calendula.ui
+
+/**
+ * A navigation a home-screen widget asked the app to perform when launched.
+ * Parsed from the launch intent in MainActivity and consumed once by
+ * [CalendarHost] (event taps reuse the existing reminder detail-key channel, so
+ * they are not modelled here).
+ */
+sealed interface WidgetNavRequest {
+ /** Open the day view anchored on [dateIso] (an ISO `yyyy-MM-dd` date). */
+ data class OpenDate(val dateIso: String) : WidgetNavRequest
+
+ /** Open the create-event form prefilled for [dateIso] (today when null). */
+ data class Create(val dateIso: String?) : WidgetNavRequest
+}
diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/agenda/AgendaUiState.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/agenda/AgendaUiState.kt
index 80fefae..2ba5c3b 100644
--- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/agenda/AgendaUiState.kt
+++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/agenda/AgendaUiState.kt
@@ -3,6 +3,8 @@ package de.jeanlucmakiola.calendula.ui.agenda
import de.jeanlucmakiola.calendula.domain.EventInstance
import de.jeanlucmakiola.calendula.domain.FailureReason
import kotlinx.datetime.LocalDate
+import kotlinx.datetime.TimeZone
+import kotlinx.datetime.toLocalDateTime
/** One calendar day with at least one event, for the agenda list. */
data class AgendaDay(
@@ -11,6 +13,34 @@ data class AgendaDay(
val events: List,
)
+/**
+ * Group flat [instances] into forward-looking [AgendaDay]s (only days that
+ * actually carry events). An event that began before [anchor] (ongoing or
+ * multi-day) is clamped to the anchor day so it still surfaces on top. Within a
+ * day, all-day events sort first, then ascending by start time, then title.
+ *
+ * Shared by the Agenda screen and the agenda home-screen widget so both group
+ * and order identically.
+ */
+fun groupAgendaDays(
+ anchor: LocalDate,
+ instances: List,
+ zone: TimeZone,
+): List =
+ instances
+ .groupBy { it.start.toLocalDateTime(zone).date.coerceAtLeast(anchor) }
+ .toSortedMap()
+ .map { (date, dayEvents) ->
+ AgendaDay(
+ date = date,
+ events = dayEvents.sortedWith(
+ compareByDescending { it.isAllDay }
+ .thenBy { it.start }
+ .thenBy { it.title },
+ ),
+ )
+ }
+
/**
* State for the Agenda view: a flat, forward-looking list of upcoming events
* grouped by day (only days that actually have events appear).
diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/agenda/AgendaViewModel.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/agenda/AgendaViewModel.kt
index 5f2b700..226938d 100644
--- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/agenda/AgendaViewModel.kt
+++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/agenda/AgendaViewModel.kt
@@ -83,21 +83,7 @@ class AgendaViewModel @Inject constructor(
if (calendars.isEmpty()) {
return AgendaUiState.Failure(FailureReason.NoCalendarsConfigured)
}
- val days = instances
- // An event that began before the window (ongoing/multi-day) still
- // overlaps it; clamp its day to the anchor so it surfaces on top.
- .groupBy { it.start.toLocalDateTime(zone).date.coerceAtLeast(anchor) }
- .toSortedMap()
- .map { (date, dayEvents) ->
- AgendaDay(
- date = date,
- events = dayEvents.sortedWith(
- compareByDescending { it.isAllDay }
- .thenBy { it.start }
- .thenBy { it.title },
- ),
- )
- }
+ val days = groupAgendaDays(anchor, instances, zone)
return AgendaUiState.Success(anchor = anchor, today = todayDate, days = days)
}
}
diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthViewModel.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthViewModel.kt
index 57ea003..6f00069 100644
--- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthViewModel.kt
+++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthViewModel.kt
@@ -113,53 +113,57 @@ class MonthViewModel @Inject constructor(
return MonthUiState.Success(
month = ym,
today = todayDate,
- weeks = layoutMonth(ym, weekStart, instances),
+ weeks = layoutMonthWeeks(ym, weekStart, instances, zone),
)
}
+}
- /**
- * Split the grid into week rows and resolve each row's events. An event is a
- * spanning bar when it's all-day or touches more than one of the row's days;
- * everything else is a single-day timed pill. Bars get lanes from the shared
- * [layoutAllDay] so a multi-day event stays on one row across the week.
- */
- private fun layoutMonth(
- ym: YearMonth,
- weekStart: DayOfWeek,
- instances: List,
- ): List {
- val firstOfMonth = LocalDate(ym.year, ym.month, 1)
- val gridStart = firstOfMonth.startOfGridWeek(weekStart)
- val leadOffset = ((firstOfMonth.dayOfWeek.ordinal - weekStart.ordinal) + 7) % 7
- val daysInMonth =
- java.time.YearMonth.of(ym.year, ym.month.ordinal + 1).lengthOfMonth()
- val weekCount = (leadOffset + daysInMonth + 6) / 7
+/**
+ * Split the month grid into week rows and resolve each row's events. An event is
+ * a spanning bar when it's all-day or touches more than one of the row's days;
+ * everything else is a single-day timed pill. Bars get lanes from the shared
+ * [layoutAllDay] so a multi-day event stays on one row across the week.
+ *
+ * Shared by the Month screen and the month home-screen widget so both lay out
+ * spans, lanes and per-day counts identically.
+ */
+internal fun layoutMonthWeeks(
+ ym: YearMonth,
+ weekStart: DayOfWeek,
+ instances: List,
+ zone: TimeZone,
+): List {
+ val firstOfMonth = LocalDate(ym.year, ym.month, 1)
+ val gridStart = firstOfMonth.startOfGridWeek(weekStart)
+ val leadOffset = ((firstOfMonth.dayOfWeek.ordinal - weekStart.ordinal) + 7) % 7
+ val daysInMonth =
+ java.time.YearMonth.of(ym.year, ym.month.ordinal + 1).lengthOfMonth()
+ val weekCount = (leadOffset + daysInMonth + 6) / 7
- return (0 until weekCount).map { row ->
- val days = (0 until 7).map { gridStart.plus(row * 7 + it, DateTimeUnit.DAY) }
- val weekEvents = instances.filter { ev -> days.any { ev.coversDay(it, zone) } }
- val (bars, singles) = weekEvents.partition { ev ->
- ev.isAllDay || days.count { ev.coversDay(it, zone) } > 1
- }
- val spans = layoutAllDay(bars, days, zone).map { s ->
- MonthSpan(
- event = s.event,
- startCol = s.startCol,
- endCol = s.endCol,
- lane = s.lane,
- continuesLeft = s.event.coversDay(days.first().minus(1, DateTimeUnit.DAY), zone),
- continuesRight = s.event.coversDay(days.last().plus(1, DateTimeUnit.DAY), zone),
- )
- }
- MonthWeek(
- days = days,
- spans = spans,
- timedByDay = days.associateWith { d ->
- singles.filter { it.coversDay(d, zone) }.sortedBy { it.start }
- },
- countByDay = days.associateWith { d -> weekEvents.count { it.coversDay(d, zone) } },
+ return (0 until weekCount).map { row ->
+ val days = (0 until 7).map { gridStart.plus(row * 7 + it, DateTimeUnit.DAY) }
+ val weekEvents = instances.filter { ev -> days.any { ev.coversDay(it, zone) } }
+ val (bars, singles) = weekEvents.partition { ev ->
+ ev.isAllDay || days.count { ev.coversDay(it, zone) } > 1
+ }
+ val spans = layoutAllDay(bars, days, zone).map { s ->
+ MonthSpan(
+ event = s.event,
+ startCol = s.startCol,
+ endCol = s.endCol,
+ lane = s.lane,
+ continuesLeft = s.event.coversDay(days.first().minus(1, DateTimeUnit.DAY), zone),
+ continuesRight = s.event.coversDay(days.last().plus(1, DateTimeUnit.DAY), zone),
)
}
+ MonthWeek(
+ days = days,
+ spans = spans,
+ timedByDay = days.associateWith { d ->
+ singles.filter { it.coversDay(d, zone) }.sortedBy { it.start }
+ },
+ countByDay = days.associateWith { d -> weekEvents.count { it.coversDay(d, zone) } },
+ )
}
}
diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/widget/WidgetData.kt b/app/src/main/java/de/jeanlucmakiola/calendula/widget/WidgetData.kt
new file mode 100644
index 0000000..854a5e0
--- /dev/null
+++ b/app/src/main/java/de/jeanlucmakiola/calendula/widget/WidgetData.kt
@@ -0,0 +1,113 @@
+package de.jeanlucmakiola.calendula.widget
+
+import android.Manifest
+import android.content.Context
+import android.content.pm.PackageManager
+import androidx.core.content.ContextCompat
+import de.jeanlucmakiola.calendula.data.prefs.resolveFirstDay
+import de.jeanlucmakiola.calendula.domain.EventInstance
+import de.jeanlucmakiola.calendula.ui.agenda.AgendaDay
+import de.jeanlucmakiola.calendula.ui.agenda.agendaRange
+import de.jeanlucmakiola.calendula.ui.agenda.groupAgendaDays
+import kotlinx.coroutines.flow.first
+import kotlinx.datetime.DateTimeUnit
+import kotlinx.datetime.DayOfWeek
+import kotlinx.datetime.LocalDate
+import kotlinx.datetime.TimeZone
+import kotlinx.datetime.atStartOfDayIn
+import kotlinx.datetime.atTime
+import kotlinx.datetime.minus
+import kotlinx.datetime.plus
+import kotlinx.datetime.toInstant
+import kotlinx.datetime.toLocalDateTime
+import java.util.Locale
+import kotlin.time.Clock
+
+/** How far ahead the agenda widget loads (a month of upcoming events). */
+private const val AGENDA_WIDGET_DAYS = 30
+
+/**
+ * How far either side of today the month widget pre-loads. The displayed month
+ * is chosen reactively in the composition, so one wide read covers ~13 months of
+ * prev/next navigation without re-querying on every arrow tap.
+ */
+private const val MONTH_WIDGET_RANGE_DAYS = 400
+
+internal fun systemZone(): TimeZone = TimeZone.currentSystemDefault()
+
+internal fun today(zone: TimeZone): LocalDate =
+ Clock.System.now().toLocalDateTime(zone).date
+
+internal fun Context.hasCalendarPermission(): Boolean =
+ ContextCompat.checkSelfPermission(this, Manifest.permission.READ_CALENDAR) ==
+ PackageManager.PERMISSION_GRANTED
+
+/** Snapshot rendered by the agenda widget. */
+sealed interface AgendaWidgetData {
+ /** Calendar permission not granted — the widget can't read events. */
+ data object NeedsPermission : AgendaWidgetData
+ data class Ready(val today: LocalDate, val days: List) : 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.
+ */
+sealed interface MonthWidgetSource {
+ data object NeedsPermission : MonthWidgetSource
+ data class Ready(
+ val today: LocalDate,
+ val weekStart: DayOfWeek,
+ val instances: List,
+ ) : MonthWidgetSource
+}
+
+/**
+ * Process-lived cache of the wide month window. Month navigation re-runs
+ * `provideGlance` (via `updateAll`), and re-querying ~13 months of instances on
+ * every arrow tap is what made paging feel sluggish — so we load once and reuse
+ * the same snapshot for every nearby month. Invalidated by
+ * [invalidateMonthWidgetCache] when calendar data changes (the freshness
+ * receiver), and automatically when the day rolls over (the `today` guard).
+ */
+internal object MonthWidgetCache {
+ @Volatile
+ var data: MonthWidgetSource.Ready? = null
+}
+
+internal fun invalidateMonthWidgetCache() {
+ MonthWidgetCache.data = null
+}
+
+/**
+ * One-shot read of the upcoming agenda for the widget. Reuses the app's
+ * [agendaRange] window and [groupAgendaDays] grouping, and the repository's
+ * [first]-emitted snapshot already has hidden calendars filtered out.
+ */
+internal suspend fun Context.loadAgendaWidgetData(): AgendaWidgetData {
+ if (!hasCalendarPermission()) return AgendaWidgetData.NeedsPermission
+ val zone = systemZone()
+ val anchor = today(zone)
+ val repo = widgetEntryPoint().calendarRepository()
+ val instances = repo.instances(agendaRange(anchor, AGENDA_WIDGET_DAYS, zone)).first()
+ return AgendaWidgetData.Ready(today = anchor, days = groupAgendaDays(anchor, instances, zone))
+}
+
+/** One-shot wide read backing the month widget's grid for any nearby month. */
+internal suspend fun Context.loadMonthWidgetSource(): MonthWidgetSource {
+ if (!hasCalendarPermission()) return MonthWidgetSource.NeedsPermission
+ val zone = systemZone()
+ val anchor = today(zone)
+ // Reuse the cached window unless the day changed (then it's stale for "today").
+ MonthWidgetCache.data?.let { if (it.today == anchor) return it }
+ val ep = widgetEntryPoint()
+ val weekStart = ep.settingsPrefs().weekStart.first().resolveFirstDay(Locale.getDefault())
+ val from = anchor.minus(MONTH_WIDGET_RANGE_DAYS, DateTimeUnit.DAY).atStartOfDayIn(zone)
+ val to = anchor.plus(MONTH_WIDGET_RANGE_DAYS, DateTimeUnit.DAY).atTime(23, 59, 59).toInstant(zone)
+ val instances = ep.calendarRepository().instances(from..to).first()
+ return MonthWidgetSource.Ready(today = anchor, weekStart = weekStart, instances = instances)
+ .also { MonthWidgetCache.data = it }
+}
diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/widget/WidgetEntryPoint.kt b/app/src/main/java/de/jeanlucmakiola/calendula/widget/WidgetEntryPoint.kt
new file mode 100644
index 0000000..177576c
--- /dev/null
+++ b/app/src/main/java/de/jeanlucmakiola/calendula/widget/WidgetEntryPoint.kt
@@ -0,0 +1,27 @@
+package de.jeanlucmakiola.calendula.widget
+
+import android.content.Context
+import dagger.hilt.EntryPoint
+import dagger.hilt.InstallIn
+import dagger.hilt.android.EntryPointAccessors
+import dagger.hilt.components.SingletonComponent
+import de.jeanlucmakiola.calendula.data.calendar.CalendarRepository
+import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
+
+/**
+ * Hilt bridge for the Glance widgets. A [androidx.glance.appwidget.GlanceAppWidget]
+ * is instantiated by the framework, not by Hilt, so it can't take constructor
+ * injection. We instead reach the singleton graph through this entry point and
+ * read the same [CalendarRepository] / [SettingsPrefs] the app uses — so widget
+ * data (hidden-calendar filtering, week-start preference, …) matches the app
+ * one-to-one.
+ */
+@EntryPoint
+@InstallIn(SingletonComponent::class)
+interface WidgetEntryPoint {
+ fun calendarRepository(): CalendarRepository
+ fun settingsPrefs(): SettingsPrefs
+}
+
+internal fun Context.widgetEntryPoint(): WidgetEntryPoint =
+ EntryPointAccessors.fromApplication(applicationContext, WidgetEntryPoint::class.java)
diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/widget/WidgetTheme.kt b/app/src/main/java/de/jeanlucmakiola/calendula/widget/WidgetTheme.kt
new file mode 100644
index 0000000..a529f0f
--- /dev/null
+++ b/app/src/main/java/de/jeanlucmakiola/calendula/widget/WidgetTheme.kt
@@ -0,0 +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 de.jeanlucmakiola.calendula.ui.theme.CalendulaDarkFallback
+import de.jeanlucmakiola.calendula.ui.theme.CalendulaLightFallback
+
+/**
+ * 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.
+ */
+@Composable
+fun CalendulaGlanceTheme(content: @Composable () -> Unit) {
+ val colors = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
+ GlanceTheme.colors
+ } else {
+ CalendulaGlanceColors
+ }
+ GlanceTheme(colors = colors, content = content)
+}
diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/widget/WidgetUpdateReceiver.kt b/app/src/main/java/de/jeanlucmakiola/calendula/widget/WidgetUpdateReceiver.kt
new file mode 100644
index 0000000..18d5e81
--- /dev/null
+++ b/app/src/main/java/de/jeanlucmakiola/calendula/widget/WidgetUpdateReceiver.kt
@@ -0,0 +1,41 @@
+package de.jeanlucmakiola.calendula.widget
+
+import android.content.BroadcastReceiver
+import android.content.Context
+import android.content.Intent
+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
+
+/**
+ * Redraws both home-screen widgets when their data goes stale. 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.
+ * - `DATE_CHANGED` / `TIME_SET` / `TIMEZONE_CHANGED` — so "today" highlighting
+ * and the upcoming window roll over at midnight / on a clock change.
+ *
+ * Both widgets also carry an `updatePeriodMillis` backstop in their provider
+ * XML, and the month widget's refresh button forces an immediate redraw.
+ */
+class WidgetUpdateReceiver : BroadcastReceiver() {
+ override fun onReceive(context: Context, intent: Intent) {
+ val pending = goAsync()
+ val appContext = context.applicationContext
+ // 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.
+ invalidateMonthWidgetCache()
+ CoroutineScope(SupervisorJob() + Dispatchers.IO).launch {
+ try {
+ AgendaWidget().updateAll(appContext)
+ MonthWidget().updateAll(appContext)
+ } finally {
+ pending.finish()
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/widget/agenda/AgendaWidget.kt b/app/src/main/java/de/jeanlucmakiola/calendula/widget/agenda/AgendaWidget.kt
new file mode 100644
index 0000000..09afee9
--- /dev/null
+++ b/app/src/main/java/de/jeanlucmakiola/calendula/widget/agenda/AgendaWidget.kt
@@ -0,0 +1,279 @@
+package de.jeanlucmakiola.calendula.widget.agenda
+
+import android.content.Context
+import android.content.res.Configuration
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
+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.action.ActionCallback
+import androidx.glance.appwidget.action.actionRunCallback
+import androidx.glance.appwidget.action.actionStartActivity
+import androidx.glance.appwidget.cornerRadius
+import androidx.glance.appwidget.lazy.LazyColumn
+import androidx.glance.appwidget.lazy.items
+import androidx.glance.appwidget.provideContent
+import androidx.glance.appwidget.updateAll
+import androidx.glance.background
+import androidx.glance.layout.Alignment
+import androidx.glance.layout.Box
+import androidx.glance.layout.Column
+import androidx.glance.layout.Row
+import androidx.glance.layout.Spacer
+import androidx.glance.layout.fillMaxSize
+import androidx.glance.layout.fillMaxWidth
+import androidx.glance.layout.height
+import androidx.glance.layout.padding
+import androidx.glance.layout.size
+import androidx.glance.layout.width
+import androidx.glance.text.FontWeight
+import androidx.glance.text.Text
+import androidx.glance.text.TextStyle
+import de.jeanlucmakiola.calendula.MainActivity
+import de.jeanlucmakiola.calendula.R
+import de.jeanlucmakiola.calendula.domain.EventInstance
+import de.jeanlucmakiola.calendula.ui.common.pastelize
+import de.jeanlucmakiola.calendula.widget.AgendaWidgetData
+import de.jeanlucmakiola.calendula.widget.CalendulaGlanceTheme
+import de.jeanlucmakiola.calendula.widget.loadAgendaWidgetData
+import de.jeanlucmakiola.calendula.widget.systemZone
+import de.jeanlucmakiola.calendula.widget.today
+import kotlinx.datetime.DateTimeUnit
+import kotlinx.datetime.LocalDate
+import kotlinx.datetime.TimeZone
+import kotlinx.datetime.plus
+import kotlinx.datetime.toLocalDateTime
+import kotlin.time.Instant
+import java.time.format.TextStyle as JavaTextStyle
+import java.util.Locale
+
+/**
+ * "Upcoming" agenda widget — a continuously scrolling list of the next ~30 days
+ * of events grouped under day headers (the Google "Schedule" widget model).
+ * Reuses the app's [groupAgendaDays] grouping so it matches the in-app agenda.
+ */
+class AgendaWidget : GlanceAppWidget() {
+
+ override suspend fun provideGlance(context: Context, id: GlanceId) {
+ val data = context.loadAgendaWidgetData()
+ val dark = (context.resources.configuration.uiMode and
+ Configuration.UI_MODE_NIGHT_MASK) == Configuration.UI_MODE_NIGHT_YES
+ provideContent {
+ CalendulaGlanceTheme {
+ AgendaWidgetBody(data = data, dark = dark)
+ }
+ }
+ }
+}
+
+/** Re-reads the calendar and redraws the widget (header refresh button). */
+class RefreshAgendaAction : ActionCallback {
+ override suspend fun onAction(context: Context, glanceId: GlanceId, parameters: ActionParameters) {
+ AgendaWidget().updateAll(context.applicationContext)
+ }
+}
+
+/** Flat row model so the [LazyColumn] can mix day headers and events. */
+private sealed interface AgendaRow {
+ data class Header(val date: LocalDate, val today: LocalDate) : AgendaRow
+ data class Event(val event: EventInstance) : AgendaRow
+}
+
+@Composable
+private fun AgendaWidgetBody(data: AgendaWidgetData, dark: Boolean) {
+ Column(
+ modifier = GlanceModifier
+ .fillMaxSize()
+ .background(GlanceTheme.colors.surface)
+ .padding(horizontal = 8.dp, vertical = 6.dp),
+ ) {
+ AgendaHeader()
+ Spacer(GlanceModifier.height(4.dp))
+ when (data) {
+ AgendaWidgetData.NeedsPermission -> WidgetMessage(R.string.widget_needs_permission)
+ is AgendaWidgetData.Ready ->
+ if (data.days.isEmpty()) {
+ WidgetMessage(R.string.agenda_empty_title)
+ } else {
+ val rows = buildList {
+ data.days.forEach { day ->
+ add(AgendaRow.Header(day.date, data.today))
+ day.events.forEach { add(AgendaRow.Event(it)) }
+ }
+ }
+ LazyColumn(modifier = GlanceModifier.fillMaxSize()) {
+ items(rows.size) { index ->
+ when (val row = rows[index]) {
+ is AgendaRow.Header -> DayHeaderRow(row.date, row.today)
+ is AgendaRow.Event -> EventRow(row.event, dark)
+ }
+ }
+ }
+ }
+ }
+ }
+}
+
+@Composable
+private fun AgendaHeader() {
+ val context = androidx.glance.LocalContext.current
+ Row(
+ modifier = GlanceModifier.fillMaxWidth().padding(horizontal = 4.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ Text(
+ text = context.getString(R.string.widget_agenda_title),
+ style = TextStyle(
+ color = GlanceTheme.colors.primary,
+ fontSize = 16.sp,
+ fontWeight = FontWeight.Medium,
+ ),
+ modifier = GlanceModifier.defaultWeight(),
+ )
+ IconButton(
+ resId = R.drawable.ic_widget_refresh,
+ contentDescription = context.getString(R.string.widget_refresh),
+ onClick = GlanceModifier.clickable(actionRunCallback()),
+ )
+ IconButton(
+ resId = R.drawable.ic_widget_add,
+ contentDescription = context.getString(R.string.widget_new_event),
+ onClick = GlanceModifier.clickable(
+ actionStartActivity(
+ MainActivity.openCreateIntent(context, today(systemZone())),
+ ),
+ ),
+ )
+ }
+}
+
+@Composable
+private fun IconButton(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(22.dp),
+ )
+ }
+}
+
+@Composable
+private fun DayHeaderRow(date: LocalDate, today: LocalDate) {
+ val context = androidx.glance.LocalContext.current
+ Text(
+ text = agendaDayLabel(context, date, today),
+ style = TextStyle(
+ color = if (date == today) GlanceTheme.colors.primary
+ else GlanceTheme.colors.onSurfaceVariant,
+ fontSize = 13.sp,
+ fontWeight = FontWeight.Medium,
+ ),
+ modifier = GlanceModifier
+ .fillMaxWidth()
+ .padding(start = 8.dp, end = 8.dp, top = 10.dp, bottom = 4.dp)
+ .clickable(actionStartActivity(MainActivity.openDateIntent(context, date))),
+ )
+}
+
+@Composable
+private fun EventRow(event: EventInstance, dark: Boolean) {
+ val context = androidx.glance.LocalContext.current
+ val title = event.title.ifBlank { context.getString(R.string.event_untitled) }
+ Row(
+ modifier = GlanceModifier
+ .fillMaxWidth()
+ .padding(horizontal = 4.dp, vertical = 4.dp)
+ .clickable(
+ actionStartActivity(
+ MainActivity.eventDetailIntent(
+ context = context,
+ eventId = event.eventId,
+ beginMillis = event.start.toEpochMilliseconds(),
+ endMillis = event.end.toEpochMilliseconds(),
+ ),
+ ),
+ ),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ Box(
+ modifier = GlanceModifier
+ .width(5.dp)
+ .height(36.dp)
+ .cornerRadius(3.dp)
+ .background(pastelize(event.color, dark)),
+ ) {}
+ Spacer(GlanceModifier.width(10.dp))
+ Column(modifier = GlanceModifier.defaultWeight()) {
+ Text(
+ text = title,
+ maxLines = 1,
+ style = TextStyle(color = GlanceTheme.colors.onSurface, fontSize = 14.sp),
+ )
+ Text(
+ text = eventTimeSummary(context, event),
+ maxLines = 1,
+ style = TextStyle(color = GlanceTheme.colors.onSurfaceVariant, fontSize = 12.sp),
+ )
+ }
+ }
+}
+
+@Composable
+private fun WidgetMessage(resId: Int) {
+ val context = androidx.glance.LocalContext.current
+ Box(
+ modifier = GlanceModifier.fillMaxSize().padding(16.dp),
+ contentAlignment = Alignment.Center,
+ ) {
+ Text(
+ text = context.getString(resId),
+ style = TextStyle(color = GlanceTheme.colors.onSurfaceVariant, fontSize = 14.sp),
+ )
+ }
+}
+
+private fun zone(): TimeZone = systemZone()
+
+/** "Today · Wed, 17 Jun" — relative word for today/tomorrow, else the date. */
+private fun agendaDayLabel(context: Context, date: LocalDate, today: LocalDate): String {
+ val relative = when (date) {
+ today -> context.getString(R.string.agenda_header_today)
+ today.plus(1, DateTimeUnit.DAY) -> context.getString(R.string.agenda_header_tomorrow)
+ else -> null
+ }
+ val locale = Locale.getDefault()
+ val java = java.time.LocalDate.of(date.year, date.month.ordinal + 1, date.day)
+ val weekday = java.dayOfWeek.getDisplayName(JavaTextStyle.SHORT, locale)
+ val monthName = java.month.getDisplayName(JavaTextStyle.SHORT, locale)
+ val formatted = "$weekday, ${date.day} $monthName"
+ return if (relative != null) "$relative · $formatted" else formatted
+}
+
+private fun eventTimeSummary(context: Context, event: EventInstance): String {
+ val time = if (event.isAllDay) {
+ context.getString(R.string.event_detail_all_day)
+ } else {
+ "${formatTime(event.start)} – ${formatTime(event.end)}"
+ }
+ val location = event.location?.takeIf { it.isNotBlank() }
+ return if (location != null) "$time · $location" else time
+}
+
+private fun formatTime(instant: Instant): String {
+ val t = instant.toLocalDateTime(zone()).time
+ return "%02d:%02d".format(t.hour, t.minute)
+}
diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/widget/agenda/AgendaWidgetReceiver.kt b/app/src/main/java/de/jeanlucmakiola/calendula/widget/agenda/AgendaWidgetReceiver.kt
new file mode 100644
index 0000000..4e1b088
--- /dev/null
+++ b/app/src/main/java/de/jeanlucmakiola/calendula/widget/agenda/AgendaWidgetReceiver.kt
@@ -0,0 +1,13 @@
+package de.jeanlucmakiola.calendula.widget.agenda
+
+import androidx.glance.appwidget.GlanceAppWidget
+import androidx.glance.appwidget.GlanceAppWidgetReceiver
+
+/**
+ * Host-facing receiver for the agenda widget. Declared in the manifest with the
+ * `appwidget_info_agenda` provider metadata; delegates all rendering to
+ * [AgendaWidget].
+ */
+class AgendaWidgetReceiver : GlanceAppWidgetReceiver() {
+ override val glanceAppWidget: GlanceAppWidget = AgendaWidget()
+}
diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/widget/month/MonthWidget.kt b/app/src/main/java/de/jeanlucmakiola/calendula/widget/month/MonthWidget.kt
new file mode 100644
index 0000000..a650be7
--- /dev/null
+++ b/app/src/main/java/de/jeanlucmakiola/calendula/widget/month/MonthWidget.kt
@@ -0,0 +1,433 @@
+package de.jeanlucmakiola.calendula.widget.month
+
+import android.content.Context
+import android.content.res.Configuration
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
+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.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.updateAppWidgetState
+import androidx.glance.appwidget.updateAll
+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.fillMaxSize
+import androidx.glance.layout.fillMaxWidth
+import androidx.glance.layout.height
+import androidx.glance.layout.padding
+import androidx.glance.layout.size
+import androidx.glance.layout.width
+import androidx.glance.state.PreferencesGlanceStateDefinition
+import androidx.glance.text.FontWeight
+import 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.pastelize
+import de.jeanlucmakiola.calendula.ui.month.MonthWeek
+import de.jeanlucmakiola.calendula.ui.month.layoutMonthWeeks
+import de.jeanlucmakiola.calendula.widget.CalendulaGlanceTheme
+import de.jeanlucmakiola.calendula.widget.MonthWidgetSource
+import de.jeanlucmakiola.calendula.widget.loadMonthWidgetSource
+import de.jeanlucmakiola.calendula.widget.systemZone
+import de.jeanlucmakiola.calendula.widget.today
+import androidx.compose.ui.unit.Dp
+import kotlinx.datetime.DayOfWeek
+import kotlinx.datetime.LocalDate
+import kotlinx.datetime.Month
+import kotlinx.datetime.TimeZone
+import kotlinx.datetime.YearMonth
+import java.time.format.TextStyle as JavaTextStyle
+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
+
+/** Dark ink that reads on the pastelized event fills, like the in-app MonthBar. */
+private val EventInk = ColorProvider(Color(0xDE000000))
+
+private fun currentMonthIndex(zone: TimeZone): Int {
+ val t = today(zone)
+ return t.year * 12 + t.month.ordinal
+}
+
+private fun yearMonthOf(index: Int): YearMonth =
+ YearMonth(index / 12, Month(index % 12 + 1))
+
+/**
+ * Month-grid widget: a 6×7 calendar with today highlighted, connected multi-day
+ * 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
+ * state and is read reactively in the composition ([currentState]) so the arrows
+ * move it via plain recomposition, not a (here-unreliable) widget session reload.
+ */
+class MonthWidget : GlanceAppWidget() {
+
+ override val stateDefinition = PreferencesGlanceStateDefinition
+ override val sizeMode = SizeMode.Exact
+
+ override suspend fun provideGlance(context: Context, id: GlanceId) {
+ val source = context.loadMonthWidgetSource()
+ val dark = (context.resources.configuration.uiMode and
+ Configuration.UI_MODE_NIGHT_MASK) == Configuration.UI_MODE_NIGHT_YES
+ provideContent {
+ CalendulaGlanceTheme {
+ MonthWidgetBody(source = source, dark = dark)
+ }
+ }
+ }
+}
+
+/** Step the displayed month by the `delta` action parameter (±1). */
+class ShiftMonthAction : ActionCallback {
+ override suspend fun onAction(context: Context, glanceId: GlanceId, parameters: ActionParameters) {
+ val delta = parameters[deltaKey] ?: 0
+ updateAppWidgetState(context, glanceId) { prefs ->
+ val cur = prefs[MONTH_INDEX_KEY] ?: currentMonthIndex(systemZone())
+ prefs[MONTH_INDEX_KEY] = cur + delta
+ }
+ MonthWidget().updateAll(context.applicationContext)
+ }
+
+ companion object {
+ val deltaKey = ActionParameters.Key("delta")
+ }
+}
+
+/** Jump the displayed month back to the current month. */
+class ResetMonthAction : ActionCallback {
+ override suspend fun onAction(context: Context, glanceId: GlanceId, parameters: ActionParameters) {
+ updateAppWidgetState(context, glanceId) { prefs -> prefs.remove(MONTH_INDEX_KEY) }
+ MonthWidget().updateAll(context.applicationContext)
+ }
+}
+
+@Composable
+private fun MonthWidgetBody(source: MonthWidgetSource, dark: Boolean) {
+ Column(
+ modifier = GlanceModifier
+ .fillMaxSize()
+ .background(GlanceTheme.colors.surface)
+ .padding(horizontal = GRID_HPADDING, vertical = 6.dp),
+ ) {
+ when (source) {
+ MonthWidgetSource.NeedsPermission -> {
+ MonthHeader(label = "Calendula")
+ PermissionMessage()
+ }
+ is MonthWidgetSource.Ready -> {
+ val zone = systemZone()
+ val index = currentState(MONTH_INDEX_KEY) ?: currentMonthIndex(zone)
+ val ym = yearMonthOf(index)
+ // Column width from the live widget size, minus our H padding.
+ val colW = (LocalSize.current.width - GRID_HPADDING * 2) / 7
+ val weeks = layoutMonthWeeks(ym, source.weekStart, source.instances, zone)
+
+ MonthHeader(label = monthLabel(ym))
+ Spacer(GlanceModifier.height(2.dp))
+ WeekdayHeader(weekStart = source.weekStart, colW = colW)
+ weeks.forEach { week ->
+ WeekRow(
+ week = week,
+ currentMonth = ym.month,
+ today = source.today,
+ dark = dark,
+ colW = colW,
+ modifier = GlanceModifier.defaultWeight(),
+ )
+ }
+ }
+ }
+ }
+}
+
+@Composable
+private fun MonthHeader(label: String) {
+ val context = LocalContext.current
+ Row(
+ modifier = GlanceModifier.fillMaxWidth(),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ HeaderIcon(
+ resId = R.drawable.ic_widget_chevron_left,
+ contentDescription = context.getString(R.string.widget_prev_month),
+ onClick = GlanceModifier.clickable(
+ actionRunCallback(
+ actionParametersOf(ShiftMonthAction.deltaKey to -1),
+ ),
+ ),
+ )
+ Text(
+ text = label,
+ style = TextStyle(
+ color = GlanceTheme.colors.primary,
+ fontSize = 15.sp,
+ fontWeight = FontWeight.Medium,
+ textAlign = TextAlign.Center,
+ ),
+ modifier = GlanceModifier
+ .defaultWeight()
+ .clickable(actionRunCallback()),
+ )
+ HeaderIcon(
+ resId = R.drawable.ic_widget_today,
+ contentDescription = context.getString(R.string.widget_today),
+ onClick = GlanceModifier.clickable(
+ actionStartActivity(MainActivity.openDateIntent(context, today(systemZone()))),
+ ),
+ )
+ HeaderIcon(
+ resId = R.drawable.ic_widget_chevron_right,
+ contentDescription = context.getString(R.string.widget_next_month),
+ onClick = GlanceModifier.clickable(
+ actionRunCallback(
+ actionParametersOf(ShiftMonthAction.deltaKey to 1),
+ ),
+ ),
+ )
+ }
+}
+
+@Composable
+private fun HeaderIcon(resId: Int, contentDescription: String, onClick: GlanceModifier) {
+ Box(
+ modifier = GlanceModifier.size(40.dp).then(onClick),
+ contentAlignment = Alignment.Center,
+ ) {
+ Image(
+ provider = ImageProvider(resId),
+ contentDescription = contentDescription,
+ colorFilter = ColorFilter.tint(GlanceTheme.colors.onSurfaceVariant),
+ modifier = GlanceModifier.size(20.dp),
+ )
+ }
+}
+
+@Composable
+private fun WeekdayHeader(weekStart: DayOfWeek, colW: Dp) {
+ Row(modifier = GlanceModifier.fillMaxWidth()) {
+ weekdayNarrowNames(weekStart).forEach { name ->
+ Text(
+ text = name,
+ style = TextStyle(
+ color = GlanceTheme.colors.onSurfaceVariant,
+ fontSize = 11.sp,
+ textAlign = TextAlign.Center,
+ ),
+ modifier = GlanceModifier.width(colW),
+ )
+ }
+ }
+}
+
+/** 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 {
+ val locale = Locale.getDefault()
+ return (0 until 7).map { i ->
+ java.time.DayOfWeek.of((weekStart.ordinal + i) % 7 + 1)
+ .getDisplayName(JavaTextStyle.NARROW, locale)
+ }
+}
+
+@Composable
+private fun WeekRow(
+ week: MonthWeek,
+ currentMonth: Month,
+ today: LocalDate,
+ dark: Boolean,
+ colW: Dp,
+ modifier: GlanceModifier,
+) {
+ Column(modifier = modifier.fillMaxWidth()) {
+ // Day numbers.
+ Row(modifier = GlanceModifier.fillMaxWidth()) {
+ 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.
+ repeat(MAX_LANES) { lane ->
+ LaneRow(week = week, lane = lane, dark = dark, colW = colW)
+ Spacer(GlanceModifier.height(1.dp))
+ }
+ OverflowRow(week = week, colW = colW)
+ }
+}
+
+@Composable
+private fun DayNumber(date: LocalDate, isToday: Boolean, inMonth: Boolean, colW: Dp) {
+ Box(
+ modifier = GlanceModifier.width(colW).height(DAY_NUMBER_HEIGHT),
+ contentAlignment = Alignment.Center,
+ ) {
+ Box(
+ modifier = GlanceModifier
+ .size(DAY_NUMBER_HEIGHT)
+ .then(if (isToday) GlanceModifier.cornerRadius(DAY_NUMBER_HEIGHT / 2).background(GlanceTheme.colors.primary) else GlanceModifier),
+ contentAlignment = Alignment.Center,
+ ) {
+ Text(
+ text = date.day.toString(),
+ 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,
+ ),
+ )
+ }
+ }
+}
+
+@Composable
+private fun LaneRow(week: MonthWeek, lane: Int, dark: Boolean, colW: Dp) {
+ Row(modifier = GlanceModifier.fillMaxWidth()) {
+ var col = 0
+ while (col < 7) {
+ val span = week.spans.firstOrNull { it.lane == lane && col in it.startCol..it.endCol }
+ if (span != null) {
+ val cols = span.endCol - col + 1
+ SpanBar(event = span.event, dark = dark, width = colW * cols)
+ col = span.endCol + 1
+ } else {
+ val timed = timedEventAt(week, lane, col, week.days[col])
+ if (timed != null) {
+ SpanBar(event = timed, dark = dark, width = colW)
+ } else {
+ Box(GlanceModifier.width(colW).height(LANE_HEIGHT)) {}
+ }
+ col += 1
+ }
+ }
+ }
+}
+
+/** A single connected, rounded event bar [width] wide with its clipped title. */
+@Composable
+private fun SpanBar(event: EventInstance, dark: Boolean, width: Dp) {
+ val context = LocalContext.current
+ Box(modifier = GlanceModifier.width(width).height(LANE_HEIGHT).padding(horizontal = 1.dp)) {
+ Box(
+ modifier = GlanceModifier
+ .fillMaxSize()
+ .cornerRadius(4.dp)
+ .background(pastelize(event.color, dark)),
+ contentAlignment = Alignment.CenterStart,
+ ) {
+ Text(
+ text = event.title.ifBlank { context.getString(R.string.event_untitled) },
+ maxLines = 1,
+ style = TextStyle(color = EventInk, fontSize = 9.sp),
+ modifier = GlanceModifier.padding(horizontal = 3.dp),
+ )
+ }
+ }
+}
+
+@Composable
+private fun OverflowRow(week: MonthWeek, colW: Dp) {
+ Row(modifier = GlanceModifier.fillMaxWidth()) {
+ week.days.forEachIndexed { col, date ->
+ val shownSpans = week.spans.count { col in it.startCol..it.endCol && it.lane < MAX_LANES }
+ val freeSlots = (MAX_LANES - shownSpans).coerceAtLeast(0)
+ val timedShown = minOf(freeSlots, week.timedByDay[date].orEmpty().size)
+ val hidden = (week.countByDay[date] ?: 0) - shownSpans - timedShown
+ Box(
+ modifier = GlanceModifier.width(colW).height(LANE_HEIGHT),
+ contentAlignment = Alignment.CenterStart,
+ ) {
+ if (hidden > 0) {
+ Text(
+ text = "+$hidden",
+ maxLines = 1,
+ style = TextStyle(color = GlanceTheme.colors.onSurfaceVariant, fontSize = 9.sp),
+ modifier = GlanceModifier.padding(start = 3.dp),
+ )
+ }
+ }
+ }
+ }
+}
+
+/** 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
+ Box(
+ modifier = GlanceModifier.fillMaxSize().padding(16.dp),
+ contentAlignment = Alignment.Center,
+ ) {
+ Text(
+ text = context.getString(R.string.widget_needs_permission),
+ style = TextStyle(
+ color = GlanceTheme.colors.onSurfaceVariant,
+ fontSize = 14.sp,
+ textAlign = TextAlign.Center,
+ ),
+ )
+ }
+}
+
+private fun monthLabel(month: YearMonth): String {
+ val locale = Locale.getDefault()
+ val name = java.time.Month.of(month.month.ordinal + 1)
+ .getDisplayName(JavaTextStyle.FULL, locale)
+ return "$name ${month.year}"
+}
diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/widget/month/MonthWidgetReceiver.kt b/app/src/main/java/de/jeanlucmakiola/calendula/widget/month/MonthWidgetReceiver.kt
new file mode 100644
index 0000000..e450f0c
--- /dev/null
+++ b/app/src/main/java/de/jeanlucmakiola/calendula/widget/month/MonthWidgetReceiver.kt
@@ -0,0 +1,12 @@
+package de.jeanlucmakiola.calendula.widget.month
+
+import androidx.glance.appwidget.GlanceAppWidget
+import androidx.glance.appwidget.GlanceAppWidgetReceiver
+
+/**
+ * Host-facing receiver for the month widget. Declared in the manifest with the
+ * `appwidget_info_month` provider metadata; delegates rendering to [MonthWidget].
+ */
+class MonthWidgetReceiver : GlanceAppWidgetReceiver() {
+ override val glanceAppWidget: GlanceAppWidget = MonthWidget()
+}
diff --git a/app/src/main/res/drawable/ic_shortcut_new_event.xml b/app/src/main/res/drawable/ic_shortcut_new_event.xml
new file mode 100644
index 0000000..e3a205b
--- /dev/null
+++ b/app/src/main/res/drawable/ic_shortcut_new_event.xml
@@ -0,0 +1,26 @@
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/res/drawable/ic_widget_add.xml b/app/src/main/res/drawable/ic_widget_add.xml
new file mode 100644
index 0000000..4797a8b
--- /dev/null
+++ b/app/src/main/res/drawable/ic_widget_add.xml
@@ -0,0 +1,6 @@
+
+
+
diff --git a/app/src/main/res/drawable/ic_widget_chevron_left.xml b/app/src/main/res/drawable/ic_widget_chevron_left.xml
new file mode 100644
index 0000000..72ec5df
--- /dev/null
+++ b/app/src/main/res/drawable/ic_widget_chevron_left.xml
@@ -0,0 +1,6 @@
+
+
+
diff --git a/app/src/main/res/drawable/ic_widget_chevron_right.xml b/app/src/main/res/drawable/ic_widget_chevron_right.xml
new file mode 100644
index 0000000..4dc2680
--- /dev/null
+++ b/app/src/main/res/drawable/ic_widget_chevron_right.xml
@@ -0,0 +1,6 @@
+
+
+
diff --git a/app/src/main/res/drawable/ic_widget_refresh.xml b/app/src/main/res/drawable/ic_widget_refresh.xml
new file mode 100644
index 0000000..ee48912
--- /dev/null
+++ b/app/src/main/res/drawable/ic_widget_refresh.xml
@@ -0,0 +1,6 @@
+
+
+
diff --git a/app/src/main/res/drawable/ic_widget_today.xml b/app/src/main/res/drawable/ic_widget_today.xml
new file mode 100644
index 0000000..075ba88
--- /dev/null
+++ b/app/src/main/res/drawable/ic_widget_today.xml
@@ -0,0 +1,6 @@
+
+
+
diff --git a/app/src/main/res/drawable/preview_stripe.xml b/app/src/main/res/drawable/preview_stripe.xml
new file mode 100644
index 0000000..9351a43
--- /dev/null
+++ b/app/src/main/res/drawable/preview_stripe.xml
@@ -0,0 +1,6 @@
+
+
+
+
+
diff --git a/app/src/main/res/drawable/preview_today_circle.xml b/app/src/main/res/drawable/preview_today_circle.xml
new file mode 100644
index 0000000..6a0fb9d
--- /dev/null
+++ b/app/src/main/res/drawable/preview_today_circle.xml
@@ -0,0 +1,5 @@
+
+
+
+
diff --git a/app/src/main/res/drawable/preview_widget_bg.xml b/app/src/main/res/drawable/preview_widget_bg.xml
new file mode 100644
index 0000000..eee3dbf
--- /dev/null
+++ b/app/src/main/res/drawable/preview_widget_bg.xml
@@ -0,0 +1,6 @@
+
+
+
+
+
diff --git a/app/src/main/res/layout/widget_preview_agenda.xml b/app/src/main/res/layout/widget_preview_agenda.xml
new file mode 100644
index 0000000..75721fe
--- /dev/null
+++ b/app/src/main/res/layout/widget_preview_agenda.xml
@@ -0,0 +1,124 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/res/layout/widget_preview_month.xml b/app/src/main/res/layout/widget_preview_month.xml
new file mode 100644
index 0000000..90c2d0c
--- /dev/null
+++ b/app/src/main/res/layout/widget_preview_month.xml
@@ -0,0 +1,114 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml
index 4e8dd65..35618e4 100644
--- a/app/src/main/res/values-de/strings.xml
+++ b/app/src/main/res/values-de/strings.xml
@@ -209,6 +209,21 @@
Nichts geplant
Anstehende Termine erscheinen hier.
+
+ Anstehend
+ Calendula Agenda
+ Calendula Monat
+ Aktualisieren
+ Neuer Termin
+ Öffne Calendula, um Kalenderzugriff zu erlauben
+ Vorheriger Monat
+ Nächster Monat
+ Heute
+
+
+ Neuer Termin
+ Neuen Termin erstellen
+
Kalender
diff --git a/app/src/main/res/values-night-v31/widget_preview_colors.xml b/app/src/main/res/values-night-v31/widget_preview_colors.xml
new file mode 100644
index 0000000..26162c8
--- /dev/null
+++ b/app/src/main/res/values-night-v31/widget_preview_colors.xml
@@ -0,0 +1,8 @@
+
+
+ @android:color/system_neutral1_900
+ @android:color/system_neutral1_50
+ @android:color/system_neutral2_200
+ @android:color/system_accent1_200
+ @android:color/system_accent1_800
+
diff --git a/app/src/main/res/values-night/widget_preview_colors.xml b/app/src/main/res/values-night/widget_preview_colors.xml
new file mode 100644
index 0000000..ca6b3f7
--- /dev/null
+++ b/app/src/main/res/values-night/widget_preview_colors.xml
@@ -0,0 +1,8 @@
+
+
+ #101316
+ #E1E3E6
+ #A8ADB2
+ #A3CBE2
+ #003348
+
diff --git a/app/src/main/res/values-v31/widget_preview_colors.xml b/app/src/main/res/values-v31/widget_preview_colors.xml
new file mode 100644
index 0000000..0eb2ece
--- /dev/null
+++ b/app/src/main/res/values-v31/widget_preview_colors.xml
@@ -0,0 +1,8 @@
+
+
+ @android:color/system_neutral1_50
+ @android:color/system_neutral1_900
+ @android:color/system_neutral2_700
+ @android:color/system_accent1_600
+ @android:color/system_accent1_0
+
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index d6f5933..1abe92f 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -210,6 +210,17 @@
Nothing scheduled
Upcoming events will show up here.
+
+ Upcoming
+ Calendula agenda
+ Calendula month
+ Refresh
+ New event
+ Open Calendula to grant calendar access
+ Previous month
+ Next month
+ Today
+
Calendars
@@ -271,6 +282,10 @@
Delete calendar?
\"%1$s\" and all of its events will be permanently removed from this device.
Couldn\'t save the change.
+
+ New event
+ Create a new event
+
https://gitea.jeanlucmakiola.de/makiolaj/calendula
https://gitea.jeanlucmakiola.de/makiolaj/calendula/src/branch/main/LICENSE
diff --git a/app/src/main/res/values/widget_preview_colors.xml b/app/src/main/res/values/widget_preview_colors.xml
new file mode 100644
index 0000000..98d328b
--- /dev/null
+++ b/app/src/main/res/values/widget_preview_colors.xml
@@ -0,0 +1,11 @@
+
+
+
+ #FBFCFE
+ #191C1F
+ #6E7479
+ #3B5364
+ #FFFFFF
+
diff --git a/app/src/main/res/xml/appwidget_info_agenda.xml b/app/src/main/res/xml/appwidget_info_agenda.xml
new file mode 100644
index 0000000..12abbe3
--- /dev/null
+++ b/app/src/main/res/xml/appwidget_info_agenda.xml
@@ -0,0 +1,14 @@
+
+
diff --git a/app/src/main/res/xml/appwidget_info_month.xml b/app/src/main/res/xml/appwidget_info_month.xml
new file mode 100644
index 0000000..cfa7acb
--- /dev/null
+++ b/app/src/main/res/xml/appwidget_info_month.xml
@@ -0,0 +1,14 @@
+
+
diff --git a/app/src/main/res/xml/shortcuts.xml b/app/src/main/res/xml/shortcuts.xml
new file mode 100644
index 0000000..6f2ae4d
--- /dev/null
+++ b/app/src/main/res/xml/shortcuts.xml
@@ -0,0 +1,17 @@
+
+
+
+
+
+
+
diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml
index 265d273..0aee61f 100644
--- a/gradle/libs.versions.toml
+++ b/gradle/libs.versions.toml
@@ -24,6 +24,8 @@ turbine = "1.2.0"
hiltNavigationCompose = "1.3.0"
lifecycleCompose = "2.10.0"
androidxTestRules = "1.7.0"
+# Glance: 1.1.1 is the latest stable (1.2.0 is still rc, 1.3.0 alpha).
+glance = "1.1.1"
[libraries]
# AndroidX core
@@ -79,6 +81,10 @@ androidx-hilt-navigation-compose = { group = "androidx.hilt", name = "hilt-navig
# Lifecycle compose (for collectAsStateWithLifecycle)
androidx-lifecycle-runtime-compose = { group = "androidx.lifecycle", name = "lifecycle-runtime-compose", version.ref = "lifecycleCompose" }
+# Glance — Jetpack home-screen widgets (Compose-like RemoteViews)
+androidx-glance-appwidget = { group = "androidx.glance", name = "glance-appwidget", version.ref = "glance" }
+androidx-glance-material3 = { group = "androidx.glance", name = "glance-material3", version.ref = "glance" }
+
# Android tests - GrantPermissionRule
androidx-test-rules = { group = "androidx.test", name = "rules", version.ref = "androidxTestRules" }