From 5e6defd4c7ddc9d2eaf17b18c0a12f8d291f56fb Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Wed, 17 Jun 2026 15:33:58 +0200 Subject: [PATCH] =?UTF-8?q?release:=20cut=20v2.5.0=20=E2=80=94=20home-scre?= =?UTF-8?q?en=20widgets,=20agenda,=20jump-to-date,=20quick=20actions?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bundles the unreleased Tier 2/3 work into one release: - Home-screen widgets (Glance): an "Upcoming" agenda widget and a month-grid widget, both reusing the in-app grouping/layout (groupAgendaDays, layoutMonthWeeks) via a Hilt WidgetEntryPoint, honouring hidden-calendar filters and refreshing on PROVIDER_CHANGED / date rollover. - App shortcut: launcher long-press "New event", routed through the shared WidgetNavRequest.Create channel into the create-event form. - Agenda view and jump-to-date (already merged via #3/#4) are documented here as part of the shipped version. Bumps versionCode 20500 / versionName 2.5.0, moves the CHANGELOG Unreleased section under [2.5.0], updates ROADMAP/STATE, and adds EN+DE strings. Co-Authored-By: Claude Opus 4.8 (1M context) --- .planning/ROADMAP.md | 8 +- .planning/STATE.md | 6 +- CHANGELOG.md | 19 + app/build.gradle.kts | 7 +- app/src/main/AndroidManifest.xml | 50 ++ .../jeanlucmakiola/calendula/MainActivity.kt | 44 ++ .../calendula/ui/CalendarHost.kt | 27 ++ .../jeanlucmakiola/calendula/ui/RootScreen.kt | 4 + .../calendula/ui/WidgetNavRequest.kt | 15 + .../calendula/ui/agenda/AgendaUiState.kt | 30 ++ .../calendula/ui/agenda/AgendaViewModel.kt | 16 +- .../calendula/ui/month/MonthViewModel.kt | 86 ++-- .../calendula/widget/WidgetData.kt | 113 +++++ .../calendula/widget/WidgetEntryPoint.kt | 27 ++ .../calendula/widget/WidgetTheme.kt | 36 ++ .../calendula/widget/WidgetUpdateReceiver.kt | 41 ++ .../calendula/widget/agenda/AgendaWidget.kt | 279 +++++++++++ .../widget/agenda/AgendaWidgetReceiver.kt | 13 + .../calendula/widget/month/MonthWidget.kt | 433 ++++++++++++++++++ .../widget/month/MonthWidgetReceiver.kt | 12 + .../res/drawable/ic_shortcut_new_event.xml | 26 ++ app/src/main/res/drawable/ic_widget_add.xml | 6 + .../res/drawable/ic_widget_chevron_left.xml | 6 + .../res/drawable/ic_widget_chevron_right.xml | 6 + .../main/res/drawable/ic_widget_refresh.xml | 6 + app/src/main/res/drawable/ic_widget_today.xml | 6 + app/src/main/res/drawable/preview_stripe.xml | 6 + .../res/drawable/preview_today_circle.xml | 5 + .../main/res/drawable/preview_widget_bg.xml | 6 + .../main/res/layout/widget_preview_agenda.xml | 124 +++++ .../main/res/layout/widget_preview_month.xml | 114 +++++ app/src/main/res/values-de/strings.xml | 15 + .../widget_preview_colors.xml | 8 + .../values-night/widget_preview_colors.xml | 8 + .../res/values-v31/widget_preview_colors.xml | 8 + app/src/main/res/values/strings.xml | 15 + .../main/res/values/widget_preview_colors.xml | 11 + .../main/res/xml/appwidget_info_agenda.xml | 14 + app/src/main/res/xml/appwidget_info_month.xml | 14 + app/src/main/res/xml/shortcuts.xml | 17 + gradle/libs.versions.toml | 6 + 41 files changed, 1629 insertions(+), 64 deletions(-) create mode 100644 app/src/main/java/de/jeanlucmakiola/calendula/ui/WidgetNavRequest.kt create mode 100644 app/src/main/java/de/jeanlucmakiola/calendula/widget/WidgetData.kt create mode 100644 app/src/main/java/de/jeanlucmakiola/calendula/widget/WidgetEntryPoint.kt create mode 100644 app/src/main/java/de/jeanlucmakiola/calendula/widget/WidgetTheme.kt create mode 100644 app/src/main/java/de/jeanlucmakiola/calendula/widget/WidgetUpdateReceiver.kt create mode 100644 app/src/main/java/de/jeanlucmakiola/calendula/widget/agenda/AgendaWidget.kt create mode 100644 app/src/main/java/de/jeanlucmakiola/calendula/widget/agenda/AgendaWidgetReceiver.kt create mode 100644 app/src/main/java/de/jeanlucmakiola/calendula/widget/month/MonthWidget.kt create mode 100644 app/src/main/java/de/jeanlucmakiola/calendula/widget/month/MonthWidgetReceiver.kt create mode 100644 app/src/main/res/drawable/ic_shortcut_new_event.xml create mode 100644 app/src/main/res/drawable/ic_widget_add.xml create mode 100644 app/src/main/res/drawable/ic_widget_chevron_left.xml create mode 100644 app/src/main/res/drawable/ic_widget_chevron_right.xml create mode 100644 app/src/main/res/drawable/ic_widget_refresh.xml create mode 100644 app/src/main/res/drawable/ic_widget_today.xml create mode 100644 app/src/main/res/drawable/preview_stripe.xml create mode 100644 app/src/main/res/drawable/preview_today_circle.xml create mode 100644 app/src/main/res/drawable/preview_widget_bg.xml create mode 100644 app/src/main/res/layout/widget_preview_agenda.xml create mode 100644 app/src/main/res/layout/widget_preview_month.xml create mode 100644 app/src/main/res/values-night-v31/widget_preview_colors.xml create mode 100644 app/src/main/res/values-night/widget_preview_colors.xml create mode 100644 app/src/main/res/values-v31/widget_preview_colors.xml create mode 100644 app/src/main/res/values/widget_preview_colors.xml create mode 100644 app/src/main/res/xml/appwidget_info_agenda.xml create mode 100644 app/src/main/res/xml/appwidget_info_month.xml create mode 100644 app/src/main/res/xml/shortcuts.xml 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" }