From 249606a358fc0a766cc4c34e48010f54b3e0cb2e Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Fri, 17 Jul 2026 09:47:58 +0200 Subject: [PATCH 01/78] fix(i18n): unify the calendar titles on one locale-aware formatter (#60) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Month, Week and Day each hand-rolled their title from a German-style template — "$weekday, ${date.day}. $monthName ${date.year}" — so every language got a trailing ordinal dot and day-before-month order, including ones that write the month first. English read "Fri, 17. Jul 2026" where it should read "Fri, Jul 17, 2026". 396a561 already fixed exactly this, but only for Agenda: it added localizedDateFormatter and migrated the four agenda files, leaving Month, Week, Day and MonthWidget on their originals. Day and Agenda therefore disagreed about the same date. All four now route through formatCalendarTitle, so there is one place left that decides how a title reads. Two behaviour changes fall out of the reporter's point that the bar wastes space: The year is dropped while you are in the current year. The title sits above a grid that already says which year it is; the year's absence is itself the signal that you are in the current one, and it reappears when you page out — the moment it starts carrying information. Since a skeleton is a field list, this is just appending "y", and the locale still places it. The Week title names a month instead of a day range. "24. Jun – 31. Jun" restated the day numbers printed in the column headers directly below, in the widest string in the bar. Naming weekStart's month keeps a straddling week on the outgoing month until it is fully gone — a week is seven contiguous days, so the earlier month has a day in it exactly while weekStart is inside it. No straddle conditional, and the title depends on nothing but weekStart, so it cannot drift with the direction you paged in from. currentLocale/localizedDateFormatter move to floret-kit's core-locale (neither is calendar-specific); LocaleSupport.kt goes away and its 11 callers repoint. MonthWidget keeps Locale.getDefault() — Glance has no LocalConfiguration and the widget re-renders on a configuration change, matching AgendaWidget. Does not touch the FAB stack the issue opens with: the buttons overlaying content is intentional and matches Google Calendar. Co-Authored-By: Claude Opus 4.8 (1M context) --- CHANGELOG.md | 19 ++++++++ .../calendula/ui/agenda/AgendaRange.kt | 2 +- .../calendula/ui/agenda/AgendaScreen.kt | 4 +- .../calendula/ui/common/CalendarTitle.kt | 28 +++++++++++ .../calendula/ui/common/LocaleSupport.kt | 33 ------------- .../calendula/ui/day/DayScreen.kt | 33 +++++++++---- .../calendula/ui/detail/EventDetailScreen.kt | 2 +- .../calendula/ui/edit/EventEditScreen.kt | 2 +- .../calendula/ui/month/MonthScreen.kt | 28 +++++++---- .../calendula/ui/search/SearchScreen.kt | 2 +- .../calendula/ui/settings/SettingsScreen.kt | 2 +- .../calendula/ui/week/WeekScreen.kt | 48 ++++++++++++------- .../calendula/widget/agenda/AgendaWidget.kt | 2 +- .../calendula/widget/month/MonthWidget.kt | 19 +++++--- floret-kit | 2 +- 15 files changed, 142 insertions(+), 84 deletions(-) create mode 100644 app/src/main/java/de/jeanlucmakiola/calendula/ui/common/CalendarTitle.kt delete mode 100644 app/src/main/java/de/jeanlucmakiola/calendula/ui/common/LocaleSupport.kt diff --git a/CHANGELOG.md b/CHANGELOG.md index 6c4de5d..367dc6a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,24 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [Unreleased] + +### Changed +- Dates in the Month, Week and Day title bars now follow your language's own + conventions. They were laid out in a fixed German-style order with a trailing + dot on the day number — "Fri, 17. Jul 2026" — in every language, including + ones that write the month first; the Agenda view already read correctly, so the + two disagreed about the same date. All four views now share one formatter and + render "Fri, Jul 17, 2026" in English, "17. Juli 2026" in German, and so on + ([#60]). +- The title bar drops the year while you're in the current one — "July" rather + than "July 2026". The year reappears the moment you page out of the current + year, which is when it tells you something you didn't already know. +- The Week view's title now names the month instead of spelling out the day range. + "24. Jun – 31. Jun" restated the day numbers already printed in the column + headers right below it, in the widest string in the bar. A week that straddles + two months keeps the outgoing month until it is fully gone ([#60]). + ## [2.15.0] — 2026-07-15 ### Added @@ -997,3 +1015,4 @@ automatically, with zero telemetry and no internet permission. [#48]: https://codeberg.org/jlmakiola/calendula/issues/48 [#49]: https://codeberg.org/jlmakiola/calendula/issues/49 [#52]: https://codeberg.org/jlmakiola/calendula/issues/52 +[#60]: https://codeberg.org/jlmakiola/calendula/issues/60 diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/agenda/AgendaRange.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/agenda/AgendaRange.kt index b4cda3f..474f48d 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/agenda/AgendaRange.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/agenda/AgendaRange.kt @@ -1,6 +1,6 @@ package de.jeanlucmakiola.calendula.ui.agenda -import de.jeanlucmakiola.calendula.ui.common.localizedDateFormatter +import de.jeanlucmakiola.floret.locale.localizedDateFormatter import kotlinx.datetime.DayOfWeek import kotlinx.datetime.LocalDate import java.time.YearMonth diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/agenda/AgendaScreen.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/agenda/AgendaScreen.kt index 51aad58..ebea45d 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/agenda/AgendaScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/agenda/AgendaScreen.kt @@ -68,7 +68,7 @@ import de.jeanlucmakiola.calendula.ui.common.CalendarFabColumn import de.jeanlucmakiola.calendula.ui.common.CalendarFailure import de.jeanlucmakiola.calendula.ui.common.CalendarView import de.jeanlucmakiola.calendula.ui.common.IMPLEMENTED_VIEWS -import de.jeanlucmakiola.calendula.ui.common.localizedDateFormatter +import de.jeanlucmakiola.floret.locale.localizedDateFormatter import de.jeanlucmakiola.calendula.ui.common.EventDimAlpha import de.jeanlucmakiola.floret.components.GroupedRow import de.jeanlucmakiola.floret.components.Position @@ -76,7 +76,7 @@ import de.jeanlucmakiola.calendula.ui.common.ViewSwitcherPill import de.jeanlucmakiola.calendula.ui.common.next import de.jeanlucmakiola.floret.components.positionOf import de.jeanlucmakiola.calendula.ui.common.rememberCurrentMinute -import de.jeanlucmakiola.calendula.ui.common.currentLocale +import de.jeanlucmakiola.floret.locale.currentLocale import de.jeanlucmakiola.calendula.ui.common.LocalUse24HourFormat import de.jeanlucmakiola.calendula.ui.common.formatTimeOfDay import kotlinx.coroutines.launch diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/CalendarTitle.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/CalendarTitle.kt new file mode 100644 index 0000000..3cb3422 --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/CalendarTitle.kt @@ -0,0 +1,28 @@ +package de.jeanlucmakiola.calendula.ui.common + +import de.jeanlucmakiola.floret.locale.localizedDateFormatter +import java.util.Locale + +/** + * Formats a calendar title (top bar or widget header): [skeleton]'s fields laid + * out in [locale]'s own order, with the year shown only when [date] falls outside + * [currentYear]. + * + * Pass [skeleton] *without* a year field — "LLLL" for a month, "EEEdMMM" for a + * day. A skeleton is a field list, so wanting the year is just asking for one + * more field; the locale still decides where it lands ("July 2026" vs "2026年7月"). + * + * Dropping the year in the current year is the one bit of policy here: the title + * sits directly above a grid that already says which year it is, and the year's + * *absence* is itself the signal that you're in the current one — it appears the + * moment you page out of it, which is when it starts carrying information. + */ +fun formatCalendarTitle( + date: java.time.LocalDate, + locale: Locale, + currentYear: Int, + skeleton: String, +): String { + val fields = if (date.year == currentYear) skeleton else skeleton + "y" + return localizedDateFormatter(locale, fields).format(date) +} diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/LocaleSupport.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/LocaleSupport.kt deleted file mode 100644 index 8fc28ef..0000000 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/LocaleSupport.kt +++ /dev/null @@ -1,33 +0,0 @@ -package de.jeanlucmakiola.calendula.ui.common - -import androidx.compose.runtime.Composable -import androidx.compose.runtime.remember -import androidx.compose.ui.platform.LocalConfiguration -import androidx.core.os.ConfigurationCompat -import java.time.format.DateTimeFormatter -import java.util.Locale - -/** - * Current display [Locale], read observably from [LocalConfiguration] so the UI - * recomposes after a locale change (lint: NonObservableLocale). Used for - * weekday/month name formatting. - */ -@Composable -fun currentLocale(): Locale { - val configuration = LocalConfiguration.current - return remember(configuration) { - ConfigurationCompat.getLocales(configuration).get(0) ?: Locale.getDefault() - } -} - -/** - * A [DateTimeFormatter] for [skeleton]'s fields laid out in [locale]'s own order - * (via Android's best-pattern matching), so dates read naturally per locale - * instead of a hardcoded day-month-year order. [skeleton] lists the wanted - * fields, e.g. "dMMMy" (day, abbreviated month, year) or "EEEdMMM" (weekday too). - */ -fun localizedDateFormatter(locale: Locale, skeleton: String): DateTimeFormatter = - DateTimeFormatter.ofPattern( - android.text.format.DateFormat.getBestDateTimePattern(locale, skeleton), - locale, - ) diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/day/DayScreen.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/day/DayScreen.kt index c1185d0..8507102 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/day/DayScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/day/DayScreen.kt @@ -68,6 +68,7 @@ import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle import de.jeanlucmakiola.calendula.R import de.jeanlucmakiola.calendula.domain.EventInstance +import de.jeanlucmakiola.calendula.ui.common.formatCalendarTitle import de.jeanlucmakiola.calendula.ui.common.CalendarDrawer import de.jeanlucmakiola.calendula.ui.common.CalendarFabColumn import de.jeanlucmakiola.calendula.ui.common.CalendarFailure @@ -83,7 +84,7 @@ import de.jeanlucmakiola.calendula.ui.common.LocalSoftenColors import de.jeanlucmakiola.calendula.ui.common.eventFill import de.jeanlucmakiola.calendula.ui.common.eventInk import de.jeanlucmakiola.calendula.ui.common.rememberCalendarSlideSpec -import de.jeanlucmakiola.calendula.ui.common.currentLocale +import de.jeanlucmakiola.floret.locale.currentLocale import de.jeanlucmakiola.calendula.ui.common.LocalUse24HourFormat import de.jeanlucmakiola.calendula.ui.common.LocalShowHourLines import de.jeanlucmakiola.calendula.ui.common.formatHourLabel @@ -93,7 +94,9 @@ import de.jeanlucmakiola.calendula.ui.week.TimedBlock import kotlinx.coroutines.flow.first import kotlinx.coroutines.launch import kotlinx.datetime.LocalDate -import java.time.format.TextStyle as JavaTextStyle +import kotlinx.datetime.TimeZone +import kotlinx.datetime.toLocalDateTime +import kotlin.time.Clock import java.util.Locale import kotlin.math.roundToInt @@ -157,6 +160,13 @@ fun DayScreen( else -> true } + // Drives whether the title carries the year. Falls back to the clock only + // while the first load is in flight, when there is no state to read today from. + val currentYear = when (val s = state) { + is DayUiState.Success -> s.today.year + else -> Clock.System.now().toLocalDateTime(TimeZone.currentSystemDefault()).date.year + } + // Slide direction for the day transition: +1 = next, -1 = prev, 0 = jump. var slideDir by remember { mutableIntStateOf(0) } val goNext = { slideDir = 1; viewModel.goToNext() } @@ -205,6 +215,7 @@ fun DayScreen( topBar = { DayTopBar( date = date, + currentYear = currentYear, selectedView = selectedView, onCycleView = { onSelectView(selectedView.next(quickSwitchViews)) }, onOpenDrawer = { scope.launch { drawerState.open() } }, @@ -360,16 +371,18 @@ private fun DaySuccess( @Composable private fun DayTopBar( date: LocalDate, + currentYear: Int, selectedView: CalendarView, onCycleView: () -> Unit, onOpenDrawer: () -> Unit, onOpenSearch: () -> Unit, scrollBehavior: androidx.compose.material3.TopAppBarScrollBehavior, ) { + val locale = currentLocale() TopAppBar( title = { Text( - text = formatDayTitle(date), + text = formatDayTitle(date, locale, currentYear), style = MaterialTheme.typography.titleLarge, ) }, @@ -669,10 +682,10 @@ private fun DayLoading() { private fun minToHm(min: Int, is24Hour: Boolean, locale: Locale): String = formatMinuteOfDay(min, is24Hour, locale) -private fun formatDayTitle(date: LocalDate): String { - 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) - return "$weekday, ${date.day}. $monthName ${date.year}" -} +private fun formatDayTitle(date: LocalDate, locale: Locale, currentYear: Int): String = + formatCalendarTitle( + date = java.time.LocalDate.of(date.year, date.month.ordinal + 1, date.day), + locale = locale, + currentYear = currentYear, + skeleton = "EEEdMMM", + ) diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/detail/EventDetailScreen.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/detail/EventDetailScreen.kt index d4073a6..3d148fe 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/detail/EventDetailScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/detail/EventDetailScreen.kt @@ -98,7 +98,7 @@ import de.jeanlucmakiola.calendula.ui.common.CalendarFailure import de.jeanlucmakiola.calendula.ui.common.LocalSoftenColors import de.jeanlucmakiola.calendula.ui.common.eventFill import de.jeanlucmakiola.floret.components.OptionCard -import de.jeanlucmakiola.calendula.ui.common.currentLocale +import de.jeanlucmakiola.floret.locale.currentLocale import de.jeanlucmakiola.calendula.ui.common.LocalUse24HourFormat import de.jeanlucmakiola.calendula.ui.common.timeOfDayFormatter import de.jeanlucmakiola.calendula.ui.common.recurrenceText diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/edit/EventEditScreen.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/edit/EventEditScreen.kt index 301cea9..11ec532 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/edit/EventEditScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/edit/EventEditScreen.kt @@ -143,7 +143,7 @@ import de.jeanlucmakiola.floret.components.positionOf import de.jeanlucmakiola.calendula.ui.common.REMINDER_PRESETS import de.jeanlucmakiola.calendula.ui.common.TimePickerAlert import de.jeanlucmakiola.floret.reminders.ReminderUnit -import de.jeanlucmakiola.calendula.ui.common.currentLocale +import de.jeanlucmakiola.floret.locale.currentLocale import de.jeanlucmakiola.calendula.ui.common.LocalUse24HourFormat import de.jeanlucmakiola.calendula.ui.common.timeOfDayFormatter import de.jeanlucmakiola.calendula.ui.common.reminderLeadTimeLabel diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthScreen.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthScreen.kt index 100c71f..fbf5e26 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthScreen.kt @@ -64,6 +64,7 @@ import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle import de.jeanlucmakiola.calendula.R import de.jeanlucmakiola.calendula.domain.hasEnded +import de.jeanlucmakiola.calendula.ui.common.formatCalendarTitle import de.jeanlucmakiola.calendula.ui.common.CalendarDrawer import de.jeanlucmakiola.calendula.ui.common.CalendarFabColumn import de.jeanlucmakiola.calendula.ui.common.CalendarFailure @@ -79,7 +80,7 @@ import de.jeanlucmakiola.calendula.ui.common.ViewSwitcherPill import de.jeanlucmakiola.calendula.ui.common.calendarSlideTransition import de.jeanlucmakiola.calendula.ui.common.rememberCalendarFadeSpec import de.jeanlucmakiola.floret.identity.rememberReduceMotion -import de.jeanlucmakiola.calendula.ui.common.currentLocale +import de.jeanlucmakiola.floret.locale.currentLocale import de.jeanlucmakiola.calendula.ui.common.rememberCalendarSlideSpec import de.jeanlucmakiola.calendula.ui.common.next import de.jeanlucmakiola.floret.time.isoWeekNumber @@ -130,6 +131,13 @@ fun MonthScreen( else -> true } + // Drives whether the title carries the year. Falls back to the clock only + // while the first load is in flight, when there is no state to read today from. + val currentYear = when (val s = state) { + is MonthUiState.Success -> s.today.year + else -> Clock.System.now().toLocalDateTime(TimeZone.currentSystemDefault()).date.year + } + // Slide direction for the grid transition: +1 = next, -1 = prev, 0 = jump (no slide). var slideDir by remember { mutableIntStateOf(0) } @@ -186,6 +194,7 @@ fun MonthScreen( topBar = { MonthTopBar( month = month, + currentYear = currentYear, selectedView = selectedView, onCycleView = { onSelectView(selectedView.next(quickSwitchViews)) }, onOpenDrawer = { scope.launch { drawerState.open() } }, @@ -294,16 +303,18 @@ private fun MonthContent( @Composable private fun MonthTopBar( month: YearMonth, + currentYear: Int, selectedView: CalendarView, onCycleView: () -> Unit, onOpenDrawer: () -> Unit, onOpenSearch: () -> Unit, scrollBehavior: androidx.compose.material3.TopAppBarScrollBehavior, ) { + val locale = currentLocale() TopAppBar( title = { Text( - text = formatMonthYear(month), + text = formatMonthTitle(month, locale, currentYear), style = MaterialTheme.typography.titleLarge, ) }, @@ -732,9 +743,10 @@ private fun MonthGridLoading() { } } -private fun formatMonthYear(ym: YearMonth): String { - val locale = Locale.getDefault() - val name = java.time.Month.of(ym.month.ordinal + 1) - .getDisplayName(JavaTextStyle.FULL, locale) - return "$name ${ym.year}" -} +private fun formatMonthTitle(ym: YearMonth, locale: Locale, currentYear: Int): String = + formatCalendarTitle( + date = java.time.LocalDate.of(ym.year, ym.month.ordinal + 1, 1), + locale = locale, + currentYear = currentYear, + skeleton = "LLLL", + ) diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/search/SearchScreen.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/search/SearchScreen.kt index 9eec23e..505e753 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/search/SearchScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/search/SearchScreen.kt @@ -54,7 +54,7 @@ import de.jeanlucmakiola.floret.identity.predictiveBack import de.jeanlucmakiola.floret.components.GroupedRow import de.jeanlucmakiola.floret.components.InlineTextField import de.jeanlucmakiola.floret.components.Position -import de.jeanlucmakiola.calendula.ui.common.currentLocale +import de.jeanlucmakiola.floret.locale.currentLocale import de.jeanlucmakiola.calendula.ui.common.LocalSoftenColors import de.jeanlucmakiola.calendula.ui.common.eventFill import de.jeanlucmakiola.calendula.ui.common.LocalUse24HourFormat diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/SettingsScreen.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/SettingsScreen.kt index 19f798b..dd5c76e 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/SettingsScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/SettingsScreen.kt @@ -135,7 +135,7 @@ import de.jeanlucmakiola.floret.components.positionOf import de.jeanlucmakiola.calendula.ui.common.reminderLeadTimeLabel import de.jeanlucmakiola.calendula.ui.common.SnoozeDurationPicker import de.jeanlucmakiola.calendula.ui.common.rememberCalendarSlideSpec -import de.jeanlucmakiola.calendula.ui.common.currentLocale +import de.jeanlucmakiola.floret.locale.currentLocale import de.jeanlucmakiola.calendula.ui.common.eventFormFieldIcon import de.jeanlucmakiola.calendula.ui.common.eventFormFieldLabel import de.jeanlucmakiola.calendula.ui.theme.BundledFont diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/week/WeekScreen.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/week/WeekScreen.kt index 251e3b4..8cba3e2 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/week/WeekScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/week/WeekScreen.kt @@ -77,6 +77,7 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle import de.jeanlucmakiola.calendula.R import de.jeanlucmakiola.calendula.domain.EventInstance import de.jeanlucmakiola.calendula.domain.hasEnded +import de.jeanlucmakiola.calendula.ui.common.formatCalendarTitle import de.jeanlucmakiola.calendula.ui.common.CalendarDrawer import de.jeanlucmakiola.calendula.ui.common.CalendarFabColumn import de.jeanlucmakiola.calendula.ui.common.CalendarFailure @@ -93,7 +94,7 @@ import de.jeanlucmakiola.calendula.ui.common.ViewSwitcherPill import de.jeanlucmakiola.calendula.ui.common.calendarSlideTransition import de.jeanlucmakiola.calendula.ui.common.rememberCalendarFadeSpec import de.jeanlucmakiola.floret.identity.rememberReduceMotion -import de.jeanlucmakiola.calendula.ui.common.currentLocale +import de.jeanlucmakiola.floret.locale.currentLocale import de.jeanlucmakiola.calendula.ui.common.LocalUse24HourFormat import de.jeanlucmakiola.calendula.ui.common.LocalShowHourLines import de.jeanlucmakiola.calendula.ui.common.formatHourLabel @@ -180,6 +181,13 @@ fun WeekScreen( else -> true } + // Drives whether the title carries the year. Falls back to the clock only + // while the first load is in flight, when there is no state to read today from. + val currentYear = when (val s = state) { + is WeekUiState.Success -> s.today.year + else -> Clock.System.now().toLocalDateTime(TimeZone.currentSystemDefault()).date.year + } + // Slide direction for the week transition: +1 = next, -1 = prev, 0 = jump. var slideDir by remember { mutableIntStateOf(0) } val goNext = { slideDir = 1; viewModel.goToNext() } @@ -228,6 +236,7 @@ fun WeekScreen( topBar = { WeekTopBar( weekStart = weekStart, + currentYear = currentYear, selectedView = selectedView, onCycleView = { onSelectView(selectedView.next(quickSwitchViews)) }, onOpenDrawer = { scope.launch { drawerState.open() } }, @@ -395,16 +404,18 @@ private fun WeekSuccess( @Composable private fun WeekTopBar( weekStart: LocalDate, + currentYear: Int, selectedView: CalendarView, onCycleView: () -> Unit, onOpenDrawer: () -> Unit, onOpenSearch: () -> Unit, scrollBehavior: androidx.compose.material3.TopAppBarScrollBehavior, ) { + val locale = currentLocale() TopAppBar( title = { Text( - text = formatWeekRange(weekStart), + text = formatWeekTitle(weekStart, locale, currentYear), style = MaterialTheme.typography.titleLarge, ) }, @@ -854,18 +865,21 @@ private fun WeekLoading() { private fun minToHm(min: Int, is24Hour: Boolean, locale: java.util.Locale): String = formatMinuteOfDay(min, is24Hour, locale) -private fun formatWeekRange(weekStart: LocalDate): String { - val locale = Locale.getDefault() - val end = weekStart.plus(6, kotlinx.datetime.DateTimeUnit.DAY) - val monthName = { d: LocalDate -> - java.time.Month.of(d.month.ordinal + 1).getDisplayName(JavaTextStyle.SHORT, locale) - } - return if (weekStart.month == end.month && weekStart.year == end.year) { - "${weekStart.day}.–${end.day}. ${monthName(weekStart)} ${weekStart.year}" - } else if (weekStart.year == end.year) { - "${weekStart.day}. ${monthName(weekStart)} – ${end.day}. ${monthName(end)} ${end.year}" - } else { - "${weekStart.day}. ${monthName(weekStart)} ${weekStart.year} – " + - "${end.day}. ${monthName(end)} ${end.year}" - } -} +/** + * The week's title: just the month [weekStart] falls in. + * + * The day numbers are already in the column headers directly below, so spelling + * out "13.–19. Jul" restates them in the widest string in the bar. Naming the + * month of [weekStart] means a week straddling a boundary keeps the outgoing + * month until it is fully gone — a week is seven contiguous days, so the earlier + * month has a day in it exactly while [weekStart] is still inside it. That also + * makes the title depend on nothing but [weekStart], so it cannot drift with the + * direction you paged in from. + */ +private fun formatWeekTitle(weekStart: LocalDate, locale: Locale, currentYear: Int): String = + formatCalendarTitle( + date = java.time.LocalDate.of(weekStart.year, weekStart.month.ordinal + 1, 1), + locale = locale, + currentYear = currentYear, + skeleton = "LLLL", + ) diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/widget/agenda/AgendaWidget.kt b/app/src/main/java/de/jeanlucmakiola/calendula/widget/agenda/AgendaWidget.kt index 039b4dc..2b048fc 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/widget/agenda/AgendaWidget.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/widget/agenda/AgendaWidget.kt @@ -55,7 +55,7 @@ import de.jeanlucmakiola.calendula.ui.agenda.parseAgendaRange import de.jeanlucmakiola.calendula.ui.common.CalendarView import de.jeanlucmakiola.calendula.ui.common.EventDimAlpha import de.jeanlucmakiola.calendula.ui.common.formatTimeOfDay -import de.jeanlucmakiola.calendula.ui.common.localizedDateFormatter +import de.jeanlucmakiola.floret.locale.localizedDateFormatter import de.jeanlucmakiola.calendula.ui.common.eventFill import de.jeanlucmakiola.calendula.widget.AgendaWidgetData import de.jeanlucmakiola.calendula.widget.CalendulaGlanceTheme diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/widget/month/MonthWidget.kt b/app/src/main/java/de/jeanlucmakiola/calendula/widget/month/MonthWidget.kt index 68b788a..24f19a3 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/widget/month/MonthWidget.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/widget/month/MonthWidget.kt @@ -49,6 +49,7 @@ import de.jeanlucmakiola.calendula.MainActivity import de.jeanlucmakiola.calendula.R import de.jeanlucmakiola.calendula.domain.EventInstance import de.jeanlucmakiola.calendula.ui.common.CalendarView +import de.jeanlucmakiola.calendula.ui.common.formatCalendarTitle import de.jeanlucmakiola.calendula.ui.month.MonthWeek import de.jeanlucmakiola.calendula.ui.month.layoutMonthWeeks import de.jeanlucmakiola.calendula.ui.common.eventFill @@ -162,7 +163,7 @@ private fun MonthWidgetBody(source: MonthWidgetSource, dark: Boolean, soften: Bo val colW = (LocalSize.current.width - GRID_HPADDING * 2) / 7 val weeks = layoutMonthWeeks(ym, source.weekStart, source.instances, zone) - MonthHeader(label = monthLabel(ym)) + MonthHeader(label = monthLabel(ym, source.today.year)) Spacer(GlanceModifier.height(2.dp)) WeekdayHeader(weekStart = source.weekStart, colW = colW) weeks.forEach { week -> @@ -480,9 +481,13 @@ private fun PermissionMessage() { } } -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}" -} +// Locale.getDefault() rather than currentLocale(): Glance has no +// LocalConfiguration, and the widget is re-rendered on a configuration change +// anyway (same convention as AgendaWidget). +private fun monthLabel(month: YearMonth, currentYear: Int): String = + formatCalendarTitle( + date = java.time.LocalDate.of(month.year, month.month.ordinal + 1, 1), + locale = Locale.getDefault(), + currentYear = currentYear, + skeleton = "LLLL", + ) diff --git a/floret-kit b/floret-kit index 2124227..45ca324 160000 --- a/floret-kit +++ b/floret-kit @@ -1 +1 @@ -Subproject commit 2124227a7f462b46b5833250bc26d2ae6c7e2cd0 +Subproject commit 45ca3243feddc7dc7aad70b8e8337e9329bff51e From accf0ac1421808a6fa9a5d91d27e939baf6f6609 Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Fri, 17 Jul 2026 14:53:13 +0200 Subject: [PATCH 02/78] release: cut 2.16.0 Locale-aware calendar titles (#60): Month, Week and Day move onto the shared formatter Agenda already used, the year drops out while you're in the current one, and the Week title names its month rather than restating the day numbers printed directly below it. Co-Authored-By: Claude Opus 4.8 (1M context) --- CHANGELOG.md | 18 +++++++++++------- app/build.gradle.kts | 4 ++-- .../android/en-US/changelogs/21600.txt | 18 ++++++++++++++++++ 3 files changed, 31 insertions(+), 9 deletions(-) create mode 100644 fastlane/metadata/android/en-US/changelogs/21600.txt diff --git a/CHANGELOG.md b/CHANGELOG.md index 367dc6a..b615505 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,14 +7,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [2.16.0] — 2026-07-17 + ### Changed -- Dates in the Month, Week and Day title bars now follow your language's own - conventions. They were laid out in a fixed German-style order with a trailing - dot on the day number — "Fri, 17. Jul 2026" — in every language, including - ones that write the month first; the Agenda view already read correctly, so the - two disagreed about the same date. All four views now share one formatter and - render "Fri, Jul 17, 2026" in English, "17. Juli 2026" in German, and so on - ([#60]). +- Dates in the Month, Week and Day title bars now follow your language and + region instead of one hardcoded layout. Every date was rendered in a fixed + German-style order with a trailing dot on the day number, whatever your + settings: US English showed "Fri, 17. Jul 2026" where it should read + "Fri, Jul 17". The Agenda view already formatted correctly, so the two + disagreed about the same date. All four views now share one formatter, and the + day/month order, the separators and the ordinal all come from your locale — + so English-in-Germany reads "Fri, 17 Jul" and English-in-the-US "Fri, Jul 17", + each correct for where you are ([#60]). - The title bar drops the year while you're in the current one — "July" rather than "July 2026". The year reappears the moment you page out of the current year, which is when it tells you something you didn't already know. diff --git a/app/build.gradle.kts b/app/build.gradle.kts index b058653..7978cfe 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -28,8 +28,8 @@ android { // which builds this version and then creates the matching vX.Y.Z tag + // release itself (versionCode is pinned to MAJOR*10000 + MINOR*100 + // PATCH from versionName, e.g. 2.7.2 -> 20702). See docs/RELEASING.md. - versionCode = 21500 - versionName = "2.15.0" + versionCode = 21600 + versionName = "2.16.0" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" } diff --git a/fastlane/metadata/android/en-US/changelogs/21600.txt b/fastlane/metadata/android/en-US/changelogs/21600.txt new file mode 100644 index 0000000..4916a79 --- /dev/null +++ b/fastlane/metadata/android/en-US/changelogs/21600.txt @@ -0,0 +1,18 @@ +### Changed +- Dates in the Month, Week and Day title bars now follow your language and + region instead of one hardcoded layout. Every date was rendered in a fixed + German-style order with a trailing dot on the day number, whatever your + settings: US English showed "Fri, 17. Jul 2026" where it should read + "Fri, Jul 17". The Agenda view already formatted correctly, so the two + disagreed about the same date. All four views now share one formatter, and the + day/month order, the separators and the ordinal all come from your locale — + so English-in-Germany reads "Fri, 17 Jul" and English-in-the-US "Fri, Jul 17", + each correct for where you are ([#60]). +- The title bar drops the year while you're in the current one — "July" rather + than "July 2026". The year reappears the moment you page out of the current + year, which is when it tells you something you didn't already know. +- The Week view's title now names the month instead of spelling out the day range. + "24. Jun – 31. Jun" restated the day numbers already printed in the column + headers right below it, in the widest string in the bar. A week that straddles + two months keeps the outgoing month until it is fully gone ([#60]). + From 60938af9f093c3bf90c7647d8559144fcb50217e Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Fri, 17 Jul 2026 14:56:55 +0200 Subject: [PATCH 03/78] chore: pin floret-kit to v0.2.0 Tracks the tagged kit main rather than the feature branch the merge left it on. A release branch pointing at an unmerged branch is the failure mode main carried since 2.15.0 (its pointer lived only on feat/custom-snooze-duration): the release pipeline builds from the tagged source tree, so a rebased or deleted branch would break the published release, not just a local checkout. Co-Authored-By: Claude Opus 4.8 (1M context) --- floret-kit | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/floret-kit b/floret-kit index 45ca324..a50878a 160000 --- a/floret-kit +++ b/floret-kit @@ -1 +1 @@ -Subproject commit 45ca3243feddc7dc7aad70b8e8337e9329bff51e +Subproject commit a50878a7ac638e725984e9cf447d693c711f52c4 From 7634df3cff91590524bfaaf68dab05aa7ae9df75 Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Fri, 17 Jul 2026 15:31:54 +0200 Subject: [PATCH 04/78] feat(domain): let an event pin its own time zone (#31) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every write sampled ZoneId.systemDefault() and stamped it into EVENT_TIMEZONE, so the column was real but only ever held the device's zone: an event synced from elsewhere could be read in its zone, never authored in one. Give EventForm a nullable `timezone`, where null keeps meaning "the device zone at save time" — so every existing call site behaves exactly as before — and a non-null value pins the event to a zone it then tracks across DST. toWriteTimes resolves the form's zone ahead of the device's; toEditForm pins only when the stored zone differs from the device's, and prefills such an event in its own zone so the form shows the wall-clock the event actually means. Two provider-contract bugs fall out of this: - Editing the time of a foreign-zone event rewrote EVENT_TIMEZONE to the device's. The instants stayed right, so nothing looked wrong, but the event silently stopped tracking its zone and would drift an hour at the next DST boundary. Only the timesChanged gate spared title-only edits. - A zone change with an untouched wall-clock is still a time change (the same 09:00 elsewhere is a different instant), so it now trips timesChanged and rewrites DTSTART instead of being dropped. All-day events keep carrying no zone at all: they're date-anchored, and the UTC midnights they normalise to are an anchor rather than a location. TimeZoneCatalog is pure JVM so the search ranking and DST-aware offsets stay plain JUnit tests. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../data/calendar/EventWriteMapper.kt | 19 ++- .../calendula/domain/EventForm.kt | 54 +++++++- .../calendula/domain/TimeZoneCatalog.kt | 123 ++++++++++++++++++ .../data/calendar/EventWriteMapperTest.kt | 65 ++++++++- .../calendula/domain/EventFormTest.kt | 52 ++++++++ .../calendula/domain/TimeZoneCatalogTest.kt | 107 +++++++++++++++ docs/ARCHITECTURE.md | 24 ++++ 7 files changed, 433 insertions(+), 11 deletions(-) create mode 100644 app/src/main/java/de/jeanlucmakiola/calendula/domain/TimeZoneCatalog.kt create mode 100644 app/src/test/java/de/jeanlucmakiola/calendula/domain/TimeZoneCatalogTest.kt diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/EventWriteMapper.kt b/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/EventWriteMapper.kt index 9758587..4053588 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/EventWriteMapper.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/EventWriteMapper.kt @@ -20,7 +20,11 @@ internal data class EventWriteTimes( /** * All-day events live at UTC midnights with an exclusive DTEND (the * CalendarContract convention — a one-day event ends at the next midnight); - * timed events resolve their wall-clock values in [zone]. + * timed events resolve their wall-clock values in the form's own + * [EventForm.timezone], falling back to [zone] (the device) when it doesn't pin + * one. Passing the device zone is therefore still correct for an unpinned form — + * but it no longer overrides a pinned event's zone, which is what used to + * silently re-anchor a foreign-zone event to the device on any time edit. */ internal fun EventForm.toWriteTimes(zone: ZoneId): EventWriteTimes = if (isAllDay) { EventWriteTimes( @@ -31,10 +35,11 @@ internal fun EventForm.toWriteTimes(zone: ZoneId): EventWriteTimes = if (isAllDa timezone = "UTC", ) } else { + val writeZone = timezone?.let { runCatching { ZoneId.of(it) }.getOrNull() } ?: zone EventWriteTimes( - dtStartMillis = start.toJavaLocalDateTime().atZone(zone).toInstant().toEpochMilli(), - dtEndMillis = end.toJavaLocalDateTime().atZone(zone).toInstant().toEpochMilli(), - timezone = zone.id, + dtStartMillis = start.toJavaLocalDateTime().atZone(writeZone).toInstant().toEpochMilli(), + dtEndMillis = end.toJavaLocalDateTime().atZone(writeZone).toInstant().toEpochMilli(), + timezone = writeZone.id, ) } @@ -134,10 +139,14 @@ internal fun buildEventUpdateValues( putAll(eventColorColumns(updated.colorKey, updated.color)) } + // A zone change counts as a time change even when the wall-clock is + // untouched: the same 09:00 in another zone is a different instant, so + // DTSTART has to move with it. val timesChanged = updated.start != original.start || updated.end != original.end || updated.isAllDay != original.isAllDay || - updated.rrule != original.rrule + updated.rrule != original.rrule || + updated.timezone != original.timezone if (!timesChanged) return@buildMap val newTimes = updated.toWriteTimes(zone) diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/domain/EventForm.kt b/app/src/main/java/de/jeanlucmakiola/calendula/domain/EventForm.kt index 5acc612..9054e20 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/domain/EventForm.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/domain/EventForm.kt @@ -9,8 +9,8 @@ import kotlin.time.Instant /** * User input for creating an event (and, from v1.3, editing one). Times are - * wall-clock values in the device zone; the data layer translates them to - * provider millis (all-day events normalise to UTC midnights there). + * wall-clock values in [timezone]; the data layer translates them to provider + * millis (all-day events normalise to UTC midnights there). */ data class EventForm( val calendarId: Long?, @@ -18,6 +18,23 @@ data class EventForm( val isAllDay: Boolean = false, val start: LocalDateTime, val end: LocalDateTime, + /** + * The zone [start]/[end] are wall-clock values in, or null to follow the + * device — null is not "no zone", it is "whichever zone the device is in + * when this is saved", which is what an event authored and lived in one + * place wants. The data layer resolves it at write time and always stamps a + * concrete `EVENT_TIMEZONE`. + * + * A non-null value pins the event to a zone regardless of where the device + * is, so it keeps tracking that zone's offset across DST. [toEditForm] only + * sets it when the stored zone differs from the device's, so merely opening + * a local event never reveals the field — and re-opening a pinned one in + * another zone round-trips it rather than silently re-anchoring it. + * + * Always null for all-day events: those are date-anchored, not zone-anchored + * (see [EventFormField.Timezone] and the data layer's UTC-midnight rule). + */ + val timezone: String? = null, val location: String = "", val description: String = "", /** Reminder lead times in minutes before the start, deduplicated. */ @@ -66,11 +83,19 @@ data class EventAttendee( /** * The form's optional sections. Which ones show by default is a user setting; - * the rest unfold behind a "more fields" button. + * the rest unfold behind a "more fields" button. Declaration order is the order + * they're offered in, so a new constant goes where it belongs on the form, not + * at the end. */ enum class EventFormField { Location, Description, + /** + * Pins the event's wall-clock times to a zone. Offered right after the time + * fields it qualifies, and suppressed entirely for all-day events, whose + * dates are deliberately zone-free. + */ + Timezone, Reminders, Recurrence, Availability, @@ -100,8 +125,25 @@ enum class EventFormProblem { * All-day provider times are UTC midnights with an exclusive end; the form * shows the last covered day and keeps placeholder wall-clock times in case * the user switches the event to timed. + * + * A timed event stored in a zone other than [zone] is prefilled *in its own + * zone* and keeps it pinned, so the wall-clock the form shows is the one the + * event means ("the New York 09:00 call") and a later save re-anchors it to the + * same zone rather than the device's. */ fun EventDetail.toEditForm(beginMillis: Long, endMillis: Long, zone: TimeZone): EventForm { + // All-day events are date-anchored and carry a nominal "UTC" that is an + // anchor, not a location, so they never pin a zone. + val pinnedZone = if (instance.isAllDay) { + null + } else { + eventTimezone + ?.takeIf { it != zone.id } + // An unparseable id (a malformed sync row) can't be honoured or + // shown; fall back to the device zone rather than failing the open. + ?.takeIf { runCatching { TimeZone.of(it) }.isSuccess } + } + val formZone = pinnedZone?.let { TimeZone.of(it) } ?: zone val (start, end) = if (instance.isAllDay) { val startDate = Instant.fromEpochMilliseconds(beginMillis) .toLocalDateTime(TimeZone.UTC).date @@ -110,8 +152,8 @@ fun EventDetail.toEditForm(beginMillis: Long, endMillis: Long, zone: TimeZone): val endDate = maxOf(startDate, LocalDate.fromEpochDays(endExclusive.toEpochDays() - 1)) LocalDateTime(startDate, LocalTime(9, 0)) to LocalDateTime(endDate, LocalTime(10, 0)) } else { - Instant.fromEpochMilliseconds(beginMillis).toLocalDateTime(zone) to - Instant.fromEpochMilliseconds(endMillis).toLocalDateTime(zone) + Instant.fromEpochMilliseconds(beginMillis).toLocalDateTime(formZone) to + Instant.fromEpochMilliseconds(endMillis).toLocalDateTime(formZone) } return EventForm( calendarId = instance.calendarId, @@ -119,6 +161,7 @@ fun EventDetail.toEditForm(beginMillis: Long, endMillis: Long, zone: TimeZone): isAllDay = instance.isAllDay, start = start, end = end, + timezone = pinnedZone, location = instance.location.orEmpty(), description = description.orEmpty(), reminders = reminders.map { it.minutes }.distinct().sorted(), @@ -184,6 +227,7 @@ fun EventDetail.toEditSnapshot(beginMillis: Long, endMillis: Long, zone: TimeZon fun EventForm.populatedFields(): Set = buildSet { if (location.isNotBlank()) add(EventFormField.Location) if (description.isNotBlank()) add(EventFormField.Description) + if (timezone != null) add(EventFormField.Timezone) if (reminders.isNotEmpty()) add(EventFormField.Reminders) if (rrule != null) add(EventFormField.Recurrence) if (availability != Availability.Busy) add(EventFormField.Availability) diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/domain/TimeZoneCatalog.kt b/app/src/main/java/de/jeanlucmakiola/calendula/domain/TimeZoneCatalog.kt new file mode 100644 index 0000000..ca08fc0 --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/calendula/domain/TimeZoneCatalog.kt @@ -0,0 +1,123 @@ +package de.jeanlucmakiola.calendula.domain + +import java.text.Normalizer +import java.time.Instant +import java.time.ZoneId +import java.time.format.TextStyle +import java.util.Locale + +/** + * One selectable zone, resolved for display: [id] is the IANA id we store in + * `EVENT_TIMEZONE`, [displayName] its localized name, and [offsetMinutes] its + * offset *at a given instant* — zones shift with DST, so an offset is only + * meaningful next to the moment it was resolved for. + */ +data class TimeZoneOption( + val id: String, + val displayName: String, + val offsetMinutes: Int, +) { + /** The trailing segment of the id ("Europe/Berlin" -> "Berlin"), underscores undone. */ + val city: String get() = id.substringAfterLast('/').replace('_', ' ') + + /** The leading segment ("Europe/Berlin" -> "Europe"); empty for bare ids like "UTC". */ + val region: String get() = id.substringBeforeLast('/', missingDelimiterValue = "") +} + +/** + * Every zone the JVM knows, localized and resolved at [at]. This is ~600 + * entries, so build it once and filter the result rather than rebuilding per + * keystroke. + * + * Bare three-letter ids ("EST", "CST6CDT") and the legacy SystemV tree are + * dropped: they're aliases the tz database keeps for compatibility, they'd + * double up the real zones in the list, and none of them is what a user means + * when they pick a place. + */ +fun timeZoneOptions( + locale: Locale = Locale.getDefault(), + at: Instant = Instant.now(), +): List = ZoneId.getAvailableZoneIds() + .asSequence() + .filter { it.contains('/') && !it.startsWith("SystemV/") } + .map { id -> + val zone = ZoneId.of(id) + TimeZoneOption( + id = id, + displayName = zone.getDisplayName(TextStyle.FULL, locale), + offsetMinutes = zone.rules.getOffset(at).totalSeconds / 60, + ) + } + .sortedWith(compareBy({ it.region }, { it.city })) + .toList() + +/** + * Resolve a single [id] the same way [timeZoneOptions] would, or null if the tz + * database doesn't know it — for labelling one known zone without paying to + * build the whole catalogue. + */ +fun timeZoneOptionOf( + id: String, + locale: Locale = Locale.getDefault(), + at: Instant = Instant.now(), +): TimeZoneOption? { + val zone = runCatching { ZoneId.of(id) }.getOrNull() ?: return null + return TimeZoneOption( + id = id, + displayName = zone.getDisplayName(TextStyle.FULL, locale), + offsetMinutes = zone.rules.getOffset(at).totalSeconds / 60, + ) +} + +/** + * [options] matching [query], best matches first; a blank query returns the list + * unchanged. Matching is accent- and case-insensitive and treats underscores as + * spaces, so "sao paulo" finds "America/Sao_Paulo". + * + * Ranking puts a city that *starts with* the query above one that merely + * contains it — typing "col" should reach Colombo before Turks_and_Caicos — + * and the id is matched ahead of the localized name so a user who knows the + * IANA id gets it first. + */ +fun filterTimeZones(options: List, query: String): List { + val needle = query.normalizeForSearch() + if (needle.isEmpty()) return options + return options + .mapNotNull { option -> + val city = option.city.normalizeForSearch() + val id = option.id.normalizeForSearch() + val name = option.displayName.normalizeForSearch() + val rank = when { + city.startsWith(needle) -> 0 + name.startsWith(needle) -> 1 + city.contains(needle) -> 2 + id.contains(needle) -> 3 + name.contains(needle) -> 4 + else -> return@mapNotNull null + } + rank to option + } + .sortedWith(compareBy({ it.first }, { it.second.city })) + .map { it.second } +} + +/** + * Lowercased, accent-stripped, underscores and slashes flattened to spaces, so + * a query types the way a place is spoken rather than the way the tz database + * spells it. + */ +private fun String.normalizeForSearch(): String = + Normalizer.normalize(this, Normalizer.Form.NFD) + .replace(Regex("\\p{Mn}+"), "") + .replace('_', ' ') + .replace('/', ' ') + .lowercase(Locale.ROOT) + .trim() + +/** "GMT+02:00" / "GMT-05:30" / "GMT" — the offset as shown next to a zone. */ +fun formatGmtOffset(offsetMinutes: Int): String { + if (offsetMinutes == 0) return "GMT" + val sign = if (offsetMinutes < 0) '-' else '+' + val abs = kotlin.math.abs(offsetMinutes) + return "GMT%c%02d:%02d".format(sign, abs / 60, abs % 60) +} diff --git a/app/src/test/java/de/jeanlucmakiola/calendula/data/calendar/EventWriteMapperTest.kt b/app/src/test/java/de/jeanlucmakiola/calendula/data/calendar/EventWriteMapperTest.kt index 972b9d9..b606a15 100644 --- a/app/src/test/java/de/jeanlucmakiola/calendula/data/calendar/EventWriteMapperTest.kt +++ b/app/src/test/java/de/jeanlucmakiola/calendula/data/calendar/EventWriteMapperTest.kt @@ -19,7 +19,14 @@ class EventWriteMapperTest { isAllDay: Boolean = false, start: LocalDateTime = LocalDateTime(LocalDate(2026, 6, 11), LocalTime(10, 0)), end: LocalDateTime = LocalDateTime(LocalDate(2026, 6, 11), LocalTime(11, 30)), - ): EventForm = EventForm(calendarId = 1L, isAllDay = isAllDay, start = start, end = end) + timezone: String? = null, + ): EventForm = EventForm( + calendarId = 1L, + isAllDay = isAllDay, + start = start, + end = end, + timezone = timezone, + ) @Test fun `timed event resolves wall clock in the given zone`() { @@ -30,6 +37,26 @@ class EventWriteMapperTest { assertThat(times.timezone).isEqualTo("Europe/Berlin") } + @Test + fun `pinned zone wins over the device zone`() { + val times = form(timezone = "America/New_York").toWriteTimes(berlin) + // 10:00 in New York (EDT, UTC-4) == 14:00Z, not 08:00Z as Berlin would give. + assertThat(times.timezone).isEqualTo("America/New_York") + assertThat(times.dtStartMillis).isEqualTo(1_781_186_400_000L) + } + + @Test + fun `an unparseable pinned zone falls back to the device zone`() { + val times = form(timezone = "Mars/Olympus_Mons").toWriteTimes(berlin) + assertThat(times.timezone).isEqualTo("Europe/Berlin") + } + + @Test + fun `all-day ignores a pinned zone and stays UTC`() { + val times = form(isAllDay = true, timezone = "America/New_York").toWriteTimes(berlin) + assertThat(times.timezone).isEqualTo("UTC") + } + @Test fun `all-day event lives at UTC midnights with exclusive end`() { val times = form(isAllDay = true).toWriteTimes(berlin) @@ -105,6 +132,42 @@ class EventWriteMapperTest { assertThat(update(original, original.copy())).isEmpty() } + @Test + fun `editing the time of a pinned event keeps its zone`() { + // The regression this guards: the update used to stamp the device zone + // over the event's own, silently un-anchoring a foreign-zone event so it + // stopped tracking that zone across DST. + val original = form(timezone = "America/New_York") + val values = update(original, original.copy(title = "Standup", start = original.start)) + assertThat(values).doesNotContainKey(CalendarContract.Events.EVENT_TIMEZONE) + + val moved = original.copy( + start = LocalDateTime(LocalDate(2026, 6, 11), LocalTime(11, 0)), + end = LocalDateTime(LocalDate(2026, 6, 11), LocalTime(12, 30)), + ) + assertThat(update(original, moved)[CalendarContract.Events.EVENT_TIMEZONE]) + .isEqualTo("America/New_York") + } + + @Test + fun `changing only the zone still moves the event`() { + // Same wall-clock, different zone: the instant moves, so DTSTART must be + // rewritten even though start/end compare equal. + val original = form() + val values = update(original, original.copy(timezone = "America/New_York")) + assertThat(values[CalendarContract.Events.EVENT_TIMEZONE]).isEqualTo("America/New_York") + // 10:00 Berlin (08:00Z) -> 10:00 New York (14:00Z): six hours later. + assertThat(values[CalendarContract.Events.DTSTART]).isEqualTo(1_781_186_400_000L) + } + + @Test + fun `unpinning back to the device zone rewrites the times`() { + val original = form(timezone = "America/New_York") + val values = update(original, original.copy(timezone = null)) + assertThat(values[CalendarContract.Events.EVENT_TIMEZONE]).isEqualTo("Europe/Berlin") + assertThat(values[CalendarContract.Events.DTSTART]).isEqualTo(1_781_164_800_000L) + } + @Test fun `text-only edit writes just the changed columns`() { val original = form() diff --git a/app/src/test/java/de/jeanlucmakiola/calendula/domain/EventFormTest.kt b/app/src/test/java/de/jeanlucmakiola/calendula/domain/EventFormTest.kt index fc975c6..a09a1b8 100644 --- a/app/src/test/java/de/jeanlucmakiola/calendula/domain/EventFormTest.kt +++ b/app/src/test/java/de/jeanlucmakiola/calendula/domain/EventFormTest.kt @@ -117,6 +117,7 @@ class EventFormTest { attendees: List = emptyList(), eventColor: Int? = null, eventColorKey: String? = null, + eventTimezone: String? = null, ): EventDetail = EventDetail( instance = EventInstance( instanceId = 1L, @@ -138,6 +139,7 @@ class EventFormTest { accessLevel = accessLevel, eventColor = eventColor, eventColorKey = eventColorKey, + eventTimezone = eventTimezone, ) @Test @@ -157,6 +159,56 @@ class EventFormTest { assertThat(prefilled.description).isEqualTo("Body") } + @Test + fun `toEditForm leaves an event in the device zone unpinned`() { + val prefilled = detail(eventTimezone = "Europe/Berlin").toEditForm( + beginMillis = 1_781_164_800_000L, + endMillis = 1_781_164_800_000L + 3_600_000L, + zone = berlin, + ) + // Same zone as the device: pinning it would only make the picker appear + // on every ordinary event. + assertThat(prefilled.timezone).isNull() + assertThat(prefilled.populatedFields()).doesNotContain(EventFormField.Timezone) + } + + @Test + fun `toEditForm pins a foreign zone and shows the times in it`() { + val prefilled = detail(eventTimezone = "America/New_York").toEditForm( + beginMillis = 1_781_164_800_000L, // 08:00Z + endMillis = 1_781_164_800_000L + 3_600_000L, + zone = berlin, + ) + assertThat(prefilled.timezone).isEqualTo("America/New_York") + // 08:00Z is 10:00 in Berlin but 04:00 in New York — the form shows the + // event's own wall-clock, which is what a later save re-anchors to. + assertThat(prefilled.start).isEqualTo(LocalDateTime(LocalDate(2026, 6, 11), LocalTime(4, 0))) + assertThat(prefilled.populatedFields()).contains(EventFormField.Timezone) + } + + @Test + fun `toEditForm never pins a zone on an all-day event`() { + // All-day rows carry a nominal "UTC" that is an anchor, not a location. + val prefilled = detail(isAllDay = true, eventTimezone = "UTC").toEditForm( + beginMillis = LocalDate(2026, 6, 11).toEpochDays() * 86_400_000L, + endMillis = LocalDate(2026, 6, 12).toEpochDays() * 86_400_000L, + zone = berlin, + ) + assertThat(prefilled.timezone).isNull() + } + + @Test + fun `toEditForm ignores an unparseable stored zone`() { + val prefilled = detail(eventTimezone = "Mars/Olympus_Mons").toEditForm( + beginMillis = 1_781_164_800_000L, + endMillis = 1_781_164_800_000L + 3_600_000L, + zone = berlin, + ) + // A malformed sync row must not be honoured, nor fail the open. + assertThat(prefilled.timezone).isNull() + assertThat(prefilled.start).isEqualTo(LocalDateTime(LocalDate(2026, 6, 11), LocalTime(10, 0))) + } + @Test fun `toEditForm turns the exclusive all-day end into the last covered day`() { // 11th..13th = UTC midnights of the 11th and the (exclusive) 14th. diff --git a/app/src/test/java/de/jeanlucmakiola/calendula/domain/TimeZoneCatalogTest.kt b/app/src/test/java/de/jeanlucmakiola/calendula/domain/TimeZoneCatalogTest.kt new file mode 100644 index 0000000..fbecfc5 --- /dev/null +++ b/app/src/test/java/de/jeanlucmakiola/calendula/domain/TimeZoneCatalogTest.kt @@ -0,0 +1,107 @@ +package de.jeanlucmakiola.calendula.domain + +import com.google.common.truth.Truth.assertThat +import org.junit.jupiter.api.Test +import java.time.Instant +import java.util.Locale + +class TimeZoneCatalogTest { + + // A fixed instant so DST-dependent offsets can't drift with the wall clock: + // 2026-06-11 is northern summer, i.e. Berlin on CEST and New York on EDT. + private val summer: Instant = Instant.parse("2026-06-11T12:00:00Z") + private val zones = timeZoneOptions(Locale.ENGLISH, summer) + + private fun filter(query: String) = filterTimeZones(zones, query) + + @Test + fun `catalogue holds the real zones and drops the legacy aliases`() { + assertThat(zones.map { it.id }).containsAtLeast("Europe/Berlin", "America/New_York") + // Bare aliases the tz database keeps for compatibility would double up + // the real zones in the picker. + assertThat(zones.map { it.id }).containsNoneOf("EST", "CST6CDT", "UTC") + assertThat(zones.none { it.id.startsWith("SystemV/") }).isTrue() + } + + @Test + fun `option exposes city and region split from the id`() { + val ny = zones.first { it.id == "America/New_York" } + assertThat(ny.city).isEqualTo("New York") + assertThat(ny.region).isEqualTo("America") + } + + @Test + fun `offset is resolved at the given instant, not the current one`() { + val berlin = zones.first { it.id == "Europe/Berlin" } + // CEST in June, so +02:00 — a fixed +01:00 would mean we ignored DST. + assertThat(berlin.offsetMinutes).isEqualTo(120) + + val winter = timeZoneOptions(Locale.ENGLISH, Instant.parse("2026-01-11T12:00:00Z")) + assertThat(winter.first { it.id == "Europe/Berlin" }.offsetMinutes).isEqualTo(60) + } + + @Test + fun `blank query returns everything unchanged`() { + assertThat(filter("")).isEqualTo(zones) + assertThat(filter(" ")).isEqualTo(zones) + } + + @Test + fun `query matches the city, ignoring case and underscores`() { + assertThat(filter("new york").map { it.id }).contains("America/New_York") + assertThat(filter("NEW YORK").map { it.id }).contains("America/New_York") + assertThat(filter("new_york").map { it.id }).contains("America/New_York") + } + + @Test + fun `query matches accented cities typed plainly`() { + // Sao_Paulo has no accent in the id, but its localized name does — the + // point is that a user typing plain ASCII still finds it. + assertThat(filter("sao paulo").map { it.id }).contains("America/Sao_Paulo") + assertThat(filter("zurich").map { it.id }).contains("Europe/Zurich") + } + + @Test + fun `query matches the full IANA id`() { + assertThat(filter("europe/berlin").map { it.id }).contains("Europe/Berlin") + } + + @Test + fun `a city starting with the query outranks one merely containing it`() { + val ids = filter("york").map { it.id } + // "New York" contains "york"; nothing starts with it, so it should still + // surface rather than being buried. + assertThat(ids).contains("America/New_York") + + // "col" starts Colombo but only appears mid-string elsewhere. + val col = filter("col").map { it.id } + assertThat(col.first()).isEqualTo("Asia/Colombo") + } + + @Test + fun `no match yields an empty list rather than everything`() { + assertThat(filter("zzzznotazone")).isEmpty() + } + + @Test + fun `single zone resolves the same way the catalogue does`() { + val fromCatalogue = zones.first { it.id == "Europe/Berlin" } + assertThat(timeZoneOptionOf("Europe/Berlin", Locale.ENGLISH, summer)) + .isEqualTo(fromCatalogue) + } + + @Test + fun `an unknown zone id resolves to null`() { + assertThat(timeZoneOptionOf("Mars/Olympus_Mons")).isNull() + } + + @Test + fun `gmt offsets format with a sign and padding`() { + assertThat(formatGmtOffset(0)).isEqualTo("GMT") + assertThat(formatGmtOffset(120)).isEqualTo("GMT+02:00") + assertThat(formatGmtOffset(-300)).isEqualTo("GMT-05:00") + // India is +05:30 — a whole-hour assumption would render this wrong. + assertThat(formatGmtOffset(330)).isEqualTo("GMT+05:30") + assertThat(formatGmtOffset(-210)).isEqualTo("GMT-03:30") + } +} diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 21d1f42..a8c3600 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -99,6 +99,30 @@ on-device — see plan 03): UTC, so zones ahead of UTC can't leak an extra occurrence. - All-day events are normalised to UTC midnights with an exclusive end. +### Event time zones + +`EventForm.timezone` is the zone its wall-clock times mean, and **null means +"the device zone at save time"** — not "no zone". The data layer resolves it in +`toWriteTimes` and always stamps a concrete `EVENT_TIMEZONE`, so an ordinary +event behaves exactly as it did before the field existed. + +- A non-null value **pins** the event: it keeps tracking that zone's offset + across DST no matter where the device is. `toEditForm` only pins when the + stored zone differs from the device's, so the optional Time-zone field stays + hidden on ordinary events and reveals itself (via `populatedFields`) on + foreign-zone ones. +- A pinned event is prefilled **in its own zone**, so the form shows the + wall-clock the event means rather than the device's rendering of it. +- A zone change counts as a **time change** even with the wall-clock untouched + (same 09:00 elsewhere is a different instant), so `buildEventUpdateValues` + includes it in `timesChanged` and rewrites `DTSTART`. +- **All-day events never carry a zone.** They're date-anchored — the UTC + midnights above are an anchor, not a location — so the field is withheld from + the form entirely and `toWriteTimes` forces `"UTC"` regardless. + +Still device-zone-relative, and knowingly so: `RRULE`'s `UNTIL` rendering and +`AllDayReminderEncoding`'s offset (see its KDoc). + ## Save conflicts No locking. `openForEdit` keeps an `EditSnapshot` — the prefilled form From fefe1f3652afc272cc145a21db04c1721f928136 Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Fri, 17 Jul 2026 15:32:04 +0200 Subject: [PATCH 05/78] feat(edit): add a searchable time-zone picker (#31) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Surface the zone as an optional form field, right after the time fields it qualifies: hidden on ordinary events, revealed automatically when the event already carries a foreign zone, and withheld entirely while all-day is on (a date-anchored event has no zone to show). The picker is a full-screen one per the app's convention, but it can't reuse OptionPicker: that composes every option eagerly, and ~600 zones would all compose on open. It drives its own LazyColumn instead, which needs the kit's new `scrollable = false` — the scaffold's own verticalScroll would otherwise throw on a nested same-axis scrollable. The device zone and recently-picked zones pin to the top so the common case needs no typing; search is accent- and case-insensitive. Recents persist in DataStore, capped at five, dropping ids the tz database no longer knows. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../calendula/data/prefs/SettingsPrefs.kt | 32 +++ .../ui/common/EventFormFieldVisuals.kt | 3 + .../calendula/ui/common/TimeZonePicker.kt | 203 ++++++++++++++++++ .../calendula/ui/edit/EventEditScreen.kt | 53 +++++ .../calendula/ui/edit/EventEditUiState.kt | 2 + .../calendula/ui/edit/EventEditViewModel.kt | 31 ++- app/src/main/res/values/strings.xml | 9 + floret-kit | 2 +- 8 files changed, 330 insertions(+), 5 deletions(-) create mode 100644 app/src/main/java/de/jeanlucmakiola/calendula/ui/common/TimeZonePicker.kt diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/data/prefs/SettingsPrefs.kt b/app/src/main/java/de/jeanlucmakiola/calendula/data/prefs/SettingsPrefs.kt index cb4e610..2b0d8ea 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/data/prefs/SettingsPrefs.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/data/prefs/SettingsPrefs.kt @@ -22,6 +22,7 @@ import de.jeanlucmakiola.calendula.ui.common.CalendarView import de.jeanlucmakiola.calendula.ui.common.IMPLEMENTED_VIEWS import de.jeanlucmakiola.calendula.ui.common.QuickSwitchConfig import de.jeanlucmakiola.calendula.ui.theme.FONT_SYSTEM_TOKEN +import java.time.ZoneId import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.map import kotlinx.datetime.DayOfWeek @@ -354,6 +355,26 @@ class SettingsPrefs @Inject constructor( } } + /** + * Zones the user picked recently, most recent first — the timezone picker's + * default list, since scrolling ~600 zones to re-find the two you actually + * use is the whole problem. Stored comma-joined (IANA ids never contain a + * comma); unparseable ids are dropped on read, so a zone the tz database + * later retires can't wedge the list. + */ + val recentTimeZones: Flow> = store.data.map { prefs -> + parseRecentTimeZones(prefs[RECENT_TIMEZONES_KEY]) + } + + suspend fun addRecentTimeZone(zoneId: String) { + store.edit { prefs -> + val updated = (listOf(zoneId) + parseRecentTimeZones(prefs[RECENT_TIMEZONES_KEY])) + .distinct() + .take(MAX_RECENT_TIME_ZONES) + prefs[RECENT_TIMEZONES_KEY] = updated.joinToString(",") + } + } + /** * Whether opening the new-event form focuses the title field and raises the * keyboard straight away (issue #10). Default ON — a new event almost always @@ -650,6 +671,13 @@ class SettingsPrefs @Inject constructor( private fun titleTemplateKey(type: SpecialDateType) = stringPreferencesKey("special_dates_title_${type.name}") + private fun parseRecentTimeZones(stored: String?): List = + stored?.split(',').orEmpty() + .map { it.trim() } + .filter { it.isNotEmpty() && runCatching { ZoneId.of(it) }.isSuccess } + .distinct() + .take(MAX_RECENT_TIME_ZONES) + private fun parseFormFields(stored: String?): Set = when (stored) { null -> DEFAULT_FORM_FIELDS else -> stored.split(',') @@ -736,6 +764,10 @@ class SettingsPrefs @Inject constructor( stringPreferencesKey("per_calendar_allday_reminder_override") internal val DEFAULT_FORM_FIELDS = setOf(EventFormField.Location, EventFormField.Description) + internal val RECENT_TIMEZONES_KEY = stringPreferencesKey("recent_time_zones") + + /** Enough to cover the zones a user actually recurs to, without a wall of rows. */ + internal const val MAX_RECENT_TIME_ZONES = 5 internal val AUTO_BACKUP_ENABLED_KEY = booleanPreferencesKey("auto_backup_enabled") internal val AUTO_BACKUP_INTERVAL_KEY = longPreferencesKey("auto_backup_interval_minutes") internal val AUTO_BACKUP_FOLDER_KEY = stringPreferencesKey("auto_backup_folder_uri") diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/EventFormFieldVisuals.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/EventFormFieldVisuals.kt index aa07543..91d84a5 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/EventFormFieldVisuals.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/EventFormFieldVisuals.kt @@ -9,6 +9,7 @@ import androidx.compose.material.icons.filled.Notifications import androidx.compose.material.icons.filled.Palette import androidx.compose.material.icons.filled.People import androidx.compose.material.icons.filled.Place +import androidx.compose.material.icons.filled.Public import androidx.compose.material.icons.filled.Repeat import androidx.compose.ui.graphics.vector.ImageVector import de.jeanlucmakiola.calendula.R @@ -24,6 +25,7 @@ import de.jeanlucmakiola.calendula.domain.EventFormField fun eventFormFieldLabel(field: EventFormField): Int = when (field) { EventFormField.Location -> R.string.event_detail_location EventFormField.Description -> R.string.event_detail_description + EventFormField.Timezone -> R.string.event_detail_timezone EventFormField.Reminders -> R.string.event_detail_reminders EventFormField.Recurrence -> R.string.event_detail_recurrence EventFormField.Availability -> R.string.event_edit_availability @@ -35,6 +37,7 @@ fun eventFormFieldLabel(field: EventFormField): Int = when (field) { fun eventFormFieldIcon(field: EventFormField): ImageVector = when (field) { EventFormField.Location -> Icons.Default.Place EventFormField.Description -> Icons.AutoMirrored.Filled.Notes + EventFormField.Timezone -> Icons.Default.Public EventFormField.Reminders -> Icons.Default.Notifications EventFormField.Recurrence -> Icons.Default.Repeat EventFormField.Availability -> Icons.Default.EventAvailable diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/TimeZonePicker.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/TimeZonePicker.kt new file mode 100644 index 0000000..4b33d87 --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/TimeZonePicker.kt @@ -0,0 +1,203 @@ +package de.jeanlucmakiola.calendula.ui.common + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Check +import androidx.compose.material.icons.filled.Public +import androidx.compose.material.icons.filled.Search +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import de.jeanlucmakiola.calendula.R +import de.jeanlucmakiola.calendula.domain.TimeZoneOption +import de.jeanlucmakiola.calendula.domain.filterTimeZones +import de.jeanlucmakiola.calendula.domain.formatGmtOffset +import de.jeanlucmakiola.calendula.domain.timeZoneOptions +import de.jeanlucmakiola.floret.components.FullScreenPicker +import de.jeanlucmakiola.floret.components.GroupedRow +import de.jeanlucmakiola.floret.components.Position +import de.jeanlucmakiola.floret.components.positionOf +import de.jeanlucmakiola.floret.locale.currentLocale + +/** + * Full-screen zone picker: a query field over every zone the JVM knows, with the + * device zone and recently-picked ones pinned on top so the common case needs no + * typing. [selected] is the pinned zone id, or null when the event follows the + * device — picking the device row reports null back, which is what keeps an + * unpinned event unpinned rather than freezing it to today's zone. + * + * Unlike the kit's [de.jeanlucmakiola.floret.components.OptionPicker], this + * drives its own [LazyColumn] (hence `scrollable = false`): ~600 options is far + * past what an eagerly composed column can carry. + */ +@Composable +fun TimeZonePickerDialog( + selected: String?, + deviceZoneId: String, + recents: List, + onSelect: (String?) -> Unit, + onDismiss: () -> Unit, +) { + var query by rememberSaveable { mutableStateOf("") } + // ~600 zones, each resolving a localized name and a DST-aware offset: build + // once per open, then filter the result per keystroke. + val locale = currentLocale() + val allZones = remember(locale) { timeZoneOptions(locale) } + val matches = remember(allZones, query) { filterTimeZones(allZones, query) } + val recentZones = remember(allZones, recents) { + recents.mapNotNull { id -> allZones.firstOrNull { it.id == id } } + } + val searching = query.isNotBlank() + + fun choose(zoneId: String?) { + onSelect(zoneId) + onDismiss() + } + + FullScreenPicker( + title = stringResource(R.string.event_detail_timezone), + onDismiss = onDismiss, + scrollable = false, + ) { + OutlinedTextField( + value = query, + onValueChange = { query = it }, + singleLine = true, + leadingIcon = { Icon(Icons.Default.Search, contentDescription = null) }, + placeholder = { Text(stringResource(R.string.event_edit_timezone_search)) }, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 8.dp), + ) + LazyColumn(contentPadding = PaddingValues(top = 8.dp, bottom = 24.dp)) { + if (!searching) { + // The device row is the "unpinned" choice, so it reads as a mode + // rather than as a zone among zones — it stays out of the big + // list and never carries an offset. + item(key = "device") { + GroupedRow( + title = stringResource(R.string.event_edit_timezone_device), + summary = zoneSummary(deviceZoneId, allZones), + position = Position.Alone, + selected = selected == null, + leading = { Icon(Icons.Default.Public, contentDescription = null) }, + trailing = if (selected == null) { + { SelectedCheck() } + } else { + null + }, + onClick = { choose(null) }, + ) + } + if (recentZones.isNotEmpty()) { + item(key = "recent_header") { + SectionHeader(stringResource(R.string.event_edit_timezone_recent)) + } + itemsIndexed( + items = recentZones, + key = { _, zone -> "recent_${zone.id}" }, + ) { index, zone -> + ZoneRow( + zone = zone, + position = positionOf(index, recentZones.size), + selected = zone.id == selected, + onClick = { choose(zone.id) }, + ) + } + } + item(key = "all_header") { + SectionHeader(stringResource(R.string.event_edit_timezone_all)) + } + } + + if (searching && matches.isEmpty()) { + item(key = "empty") { + Text( + text = stringResource(R.string.event_edit_timezone_none, query), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 32.dp, vertical = 48.dp), + ) + } + } + + itemsIndexed( + items = matches, + key = { _, zone -> "zone_${zone.id}" }, + ) { index, zone -> + ZoneRow( + zone = zone, + position = positionOf(index, matches.size), + selected = zone.id == selected, + onClick = { choose(zone.id) }, + ) + } + } + } +} + +/** A small primary-coloured group label, matching the settings screens. */ +@Composable +private fun SectionHeader(text: String) { + Text( + text = text, + style = MaterialTheme.typography.labelLarge, + color = MaterialTheme.colorScheme.primary, + modifier = Modifier.padding(start = 24.dp, end = 24.dp, top = 16.dp, bottom = 4.dp), + ) +} + +@Composable +private fun SelectedCheck() { + Icon( + imageVector = Icons.Default.Check, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + ) +} + +@Composable +private fun ZoneRow( + zone: TimeZoneOption, + position: Position, + selected: Boolean, + onClick: () -> Unit, +) { + GroupedRow( + title = zone.city, + summary = "${zone.displayName} · ${formatGmtOffset(zone.offsetMinutes)}", + position = position, + selected = selected, + trailing = if (selected) { + { SelectedCheck() } + } else { + null + }, + onClick = onClick, + ) +} + +/** "Central European Time · GMT+02:00" for [zoneId], or the bare id if unknown. */ +private fun zoneSummary(zoneId: String, allZones: List): String = + allZones.firstOrNull { it.id == zoneId } + ?.let { "${it.displayName} · ${formatGmtOffset(it.offsetMinutes)}" } + ?: zoneId diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/edit/EventEditScreen.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/edit/EventEditScreen.kt index 11ec532..67e47a0 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/edit/EventEditScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/edit/EventEditScreen.kt @@ -47,6 +47,7 @@ import androidx.compose.material.icons.filled.Person import androidx.compose.material.icons.filled.PersonAdd import androidx.compose.material.icons.filled.Place import androidx.compose.material.icons.filled.Public +import androidx.compose.material.icons.filled.Public import androidx.compose.material.icons.filled.Repeat import androidx.compose.material.icons.filled.Schedule import androidx.compose.material.icons.filled.Tune @@ -111,6 +112,8 @@ import de.jeanlucmakiola.calendula.domain.EventAttendee import de.jeanlucmakiola.calendula.domain.EventColorOption import de.jeanlucmakiola.calendula.domain.EventForm import de.jeanlucmakiola.calendula.domain.EventFormField +import de.jeanlucmakiola.calendula.domain.formatGmtOffset +import de.jeanlucmakiola.calendula.domain.timeZoneOptionOf import de.jeanlucmakiola.calendula.domain.EventFormProblem import de.jeanlucmakiola.calendula.domain.RecurrenceEnd import de.jeanlucmakiola.calendula.domain.RecurrenceFreq @@ -126,6 +129,7 @@ import de.jeanlucmakiola.calendula.data.calendar.CalendarColorPalette import de.jeanlucmakiola.calendula.ui.common.CalendarPickerGroups import de.jeanlucmakiola.calendula.ui.common.CalendarDatePickerDialog import de.jeanlucmakiola.calendula.ui.common.ColorSwatchRow +import de.jeanlucmakiola.calendula.ui.common.TimeZonePickerDialog import de.jeanlucmakiola.calendula.ui.common.LocalSoftenColors import de.jeanlucmakiola.calendula.ui.common.eventFill import de.jeanlucmakiola.floret.components.DialogAmountField @@ -160,6 +164,7 @@ import kotlinx.datetime.toJavaLocalDate import kotlinx.datetime.toKotlinDayOfWeek import kotlinx.datetime.toJavaLocalTime import kotlinx.datetime.toLocalDateTime +import java.time.ZoneId import java.time.format.DateTimeFormatter import java.time.format.FormatStyle import java.time.format.TextStyle as JavaTextStyle @@ -506,6 +511,7 @@ private fun EventEditContent( var showRecurrencePicker by rememberSaveable { mutableStateOf(false) } var showVisibilityPicker by rememberSaveable { mutableStateOf(false) } var showColorPicker by rememberSaveable { mutableStateOf(false) } + var showTimezonePicker by rememberSaveable { mutableStateOf(false) } var showFieldPicker by rememberSaveable { mutableStateOf(false) } // Pick a guest from contacts: a one-shot system picker returning the chosen @@ -706,6 +712,43 @@ private fun EventEditContent( // Optional sections: which ones render by default is a setting; the // rest unfold behind the "more fields" button below. + OptionalFormSection(visible = EventFormField.Timezone in state.visibleFields) { + Spacer(Modifier.height(gap)) + EditCard( + icon = Icons.Default.Public, + iconContentDescription = null, + onClick = { showTimezonePicker = true }, + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.fillMaxWidth(), + ) { + val pinned = remember(form.timezone, locale) { + form.timezone?.let { timeZoneOptionOf(it, locale) } + } + Column(modifier = Modifier.weight(1f)) { + Text( + text = pinned?.city + ?: stringResource(R.string.event_edit_timezone_device), + style = MaterialTheme.typography.titleMedium, + ) + Text( + text = pinned + ?.let { "${it.displayName} · ${formatGmtOffset(it.offsetMinutes)}" } + ?: stringResource(R.string.event_edit_timezone_device_summary), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + Icon( + imageVector = Icons.Default.ArrowDropDown, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } + OptionalFormSection(visible = EventFormField.Location in state.visibleFields) { Spacer(Modifier.height(gap)) EditCard( @@ -1081,6 +1124,16 @@ private fun EventEditContent( ) } + if (showTimezonePicker) { + TimeZonePickerDialog( + selected = form.timezone, + deviceZoneId = ZoneId.systemDefault().id, + recents = state.recentTimeZones, + onSelect = viewModel::setTimezone, + onDismiss = { showTimezonePicker = false }, + ) + } + if (showColorPicker) { ColorPickerDialog( palette = state.colorPalette, diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/edit/EventEditUiState.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/edit/EventEditUiState.kt index 934b792..6a68d04 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/edit/EventEditUiState.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/edit/EventEditUiState.kt @@ -27,6 +27,8 @@ data class EventEditUiState( * neither list. */ val hiddenFields: List = emptyList(), + /** Recently picked zones, most recent first — the zone picker's shortlist. */ + val recentTimeZones: List = emptyList(), /** True while editing an existing event (the calendar is then fixed). */ val isEditing: Boolean = false, /** diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/edit/EventEditViewModel.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/edit/EventEditViewModel.kt index 8308538..b339f39 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/edit/EventEditViewModel.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/edit/EventEditViewModel.kt @@ -39,6 +39,7 @@ import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext import kotlinx.datetime.LocalDate import kotlinx.datetime.LocalDateTime import kotlinx.datetime.LocalTime @@ -219,7 +220,8 @@ class EventEditViewModel @Inject constructor( ).flowOn(io), colorPalette, allCalendars, - ) { local, external, palette, allCalendars -> + settingsPrefs.recentTimeZones.flowOn(io), + ) { local, external, palette, allCalendars, recentTimeZones -> val form = local.form ?: return@combine null val resolvedId = form.calendarId ?: external.lastUsed?.takeIf { id -> external.writable.any { it.id == id } } @@ -235,14 +237,19 @@ class EventEditViewModel @Inject constructor( val pickerCalendars = if (isManaged && resolvedCalendar != null) external.writable + resolvedCalendar else external.writable - val visibleFields = external.defaultFields + local.revealed + // An all-day event is date-anchored, so a zone is meaningless on it — + // the field is withheld from both lists rather than shown as a no-op. + val offerableFields = EventFormField.entries.toSet() - + if (resolved.isAllDay) setOf(EventFormField.Timezone) else emptySet() + val visibleFields = (external.defaultFields + local.revealed) intersect offerableFields EventEditUiState( form = resolved, calendars = pickerCalendars, problems = if (local.showProblems) resolved.problems() else emptySet(), saveState = local.saveState, visibleFields = visibleFields, - hiddenFields = (EventFormField.entries.toSet() - visibleFields).sorted(), + hiddenFields = (offerableFields - visibleFields).sorted(), + recentTimeZones = recentTimeZones, isEditing = local.editTarget != null, isManaged = isManaged, autofocusTitle = external.autofocusTitle, @@ -453,12 +460,28 @@ class EventEditViewModel @Inject constructor( fun setLocation(value: String) = update { it.copy(location = value) } fun setDescription(value: String) = update { it.copy(description = value) } fun setAllDay(value: Boolean) { - update { it.copy(isAllDay = value) } + // Going all-day drops any pinned zone: the times become bare dates that + // the provider stores UTC-anchored, so a zone could only misrepresent + // them. Coming back out of all-day leaves the event on the device zone, + // which is the same thing a fresh event gets. + update { it.copy(isAllDay = value, timezone = if (value) null else it.timezone) } // The default reminder differs for all-day vs timed; re-apply the // type-appropriate default unless the user has hand-edited it (guarded). applyDefaultReminder() } + /** + * Pin the event to [zoneId], or pass null to follow the device. The zone + * rides along as a recent so the picker can offer it without a search next + * time — only real picks are remembered, not the device fallback. + */ + fun setTimezone(zoneId: String?) { + update { it.copy(timezone = zoneId) } + if (zoneId != null) { + viewModelScope.launch { withContext(io) { settingsPrefs.addRecentTimeZone(zoneId) } } + } + } + /** * Switching calendars drops any chosen colour: a palette key is * account-scoped, and a raw colour may be invalid on the new calendar. diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 06f995d..eb35d31 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -99,6 +99,15 @@ Availability Visibility + + Device time zone + Follows wherever you are + Search time zones + Recent + All time zones + No time zone matches “%1$s” + Use device zone + Color Calendar color diff --git a/floret-kit b/floret-kit index a50878a..3aa4cea 160000 --- a/floret-kit +++ b/floret-kit @@ -1 +1 @@ -Subproject commit a50878a7ac638e725984e9cf447d693c711f52c4 +Subproject commit 3aa4ceada5af0c103d8f03263612524b466f9d19 From ab3df639b98ed2012d2ca27b4f73d6f243f145f3 Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Fri, 17 Jul 2026 15:37:56 +0200 Subject: [PATCH 06/78] chore: pin floret-kit to v0.2.1 The time-zone picker needs the scaffold's `scrollable` opt-out, which landed in 0.2.1. Pins the tag rather than the branch commit the work was developed against, keeping the tag-pinning convention. Co-Authored-By: Claude Opus 4.8 (1M context) --- floret-kit | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/floret-kit b/floret-kit index 3aa4cea..df8bdba 160000 --- a/floret-kit +++ b/floret-kit @@ -1 +1 @@ -Subproject commit 3aa4ceada5af0c103d8f03263612524b466f9d19 +Subproject commit df8bdbaf73380a354b7d2ee28e08875e155c89cd From 0588609c757d88cc1d2fc9c3ee266cc250533c3c Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Fri, 17 Jul 2026 15:54:33 +0200 Subject: [PATCH 07/78] fix(edit): use the family's text input, and show both times when zones differ MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two review fixes. The zone picker's search box was a raw Material OutlinedTextField — the only one left in the app, and against the convention DialogControls states outright ("the family's InlineTextField over a tonal surface, not Material's outlined field"). Rebuild it on InlineTextField over a tonal surface, with the clear button inside the surface since the picker's top bar is the title rather than a search field. Showing a pinned event only in its own zone answered "what was it set to?" while dropping "when is it for me?" — the user had to do the offset arithmetic. Show both whenever they differ: - the edit form keeps editing the event in its own zone (that's the time it was set at) and captions it with the local equivalent; - the detail screen keeps local times primary and now leads the zone card with the original ("8:00 AM – 9:00 AM in New York") instead of naming the zone and nothing else. EventForm.timesIn is pure, so the conversion — including crossing the date line and each zone's own DST, which don't move together — is a plain JUnit test rather than something only reviewable on a phone. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../calendula/domain/EventForm.kt | 18 +++++ .../calendula/ui/common/TimeZonePicker.kt | 77 ++++++++++++++++--- .../calendula/ui/detail/EventDetailScreen.kt | 31 +++++++- .../calendula/ui/edit/EventEditScreen.kt | 32 ++++++++ app/src/main/res/values/strings.xml | 7 ++ .../calendula/domain/EventFormTest.kt | 61 +++++++++++++++ 6 files changed, 214 insertions(+), 12 deletions(-) diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/domain/EventForm.kt b/app/src/main/java/de/jeanlucmakiola/calendula/domain/EventForm.kt index 9054e20..3d0fde7 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/domain/EventForm.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/domain/EventForm.kt @@ -4,6 +4,7 @@ import kotlinx.datetime.LocalDate import kotlinx.datetime.LocalDateTime import kotlinx.datetime.LocalTime import kotlinx.datetime.TimeZone +import kotlinx.datetime.toInstant import kotlinx.datetime.toLocalDateTime import kotlin.time.Instant @@ -219,6 +220,23 @@ fun EventDetail.toEditSnapshot(beginMillis: Long, endMillis: Long, zone: TimeZon rowEnd = instance.end, ) +/** + * The form's times as they land in [target] — what a pinned event's wall-clock + * actually means where the user is standing. Null when there's nothing to + * disambiguate: an unpinned event (already in [target]), one pinned to [target] + * itself, an all-day event (no zone), or an unparseable pinned zone. + * + * The form edits a pinned event in its own zone, so this is what lets the UI + * show the other side of the pair rather than making the user do the arithmetic. + */ +fun EventForm.timesIn(target: TimeZone): Pair? { + if (isAllDay) return null + val pinned = timezone?.let { runCatching { TimeZone.of(it) }.getOrNull() } ?: return null + if (pinned.id == target.id) return null + return start.toInstant(pinned).toLocalDateTime(target) to + end.toInstant(pinned).toLocalDateTime(target) +} + /** * The optional sections that hold a value in [form] — when editing, these * must be visible regardless of the user's default-fields setting, or the diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/TimeZonePicker.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/TimeZonePicker.kt index 4b33d87..4e1579d 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/TimeZonePicker.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/TimeZonePicker.kt @@ -2,17 +2,21 @@ package de.jeanlucmakiola.calendula.ui.common import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Check +import androidx.compose.material.icons.filled.Close import androidx.compose.material.icons.filled.Public import androidx.compose.material.icons.filled.Search import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue @@ -20,8 +24,12 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalSoftwareKeyboardController import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.KeyboardCapitalization import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import de.jeanlucmakiola.calendula.R @@ -31,6 +39,7 @@ import de.jeanlucmakiola.calendula.domain.formatGmtOffset import de.jeanlucmakiola.calendula.domain.timeZoneOptions import de.jeanlucmakiola.floret.components.FullScreenPicker import de.jeanlucmakiola.floret.components.GroupedRow +import de.jeanlucmakiola.floret.components.InlineTextField import de.jeanlucmakiola.floret.components.Position import de.jeanlucmakiola.floret.components.positionOf import de.jeanlucmakiola.floret.locale.currentLocale @@ -75,15 +84,10 @@ fun TimeZonePickerDialog( onDismiss = onDismiss, scrollable = false, ) { - OutlinedTextField( - value = query, - onValueChange = { query = it }, - singleLine = true, - leadingIcon = { Icon(Icons.Default.Search, contentDescription = null) }, - placeholder = { Text(stringResource(R.string.event_edit_timezone_search)) }, - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 16.dp, vertical = 8.dp), + SearchField( + query = query, + onQueryChange = { query = it }, + modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp), ) LazyColumn(contentPadding = PaddingValues(top = 8.dp, bottom = 24.dp)) { if (!searching) { @@ -155,6 +159,59 @@ fun TimeZonePickerDialog( } } +/** + * The query box: the family's borderless [InlineTextField] over a tonal surface, + * so it reads like the rest of the app's inputs rather than a boxed Material + * field. The clear button rides inside the surface (unlike the search screen, + * which has a top bar to put it in) because the picker's bar is the title. + */ +@Composable +private fun SearchField( + query: String, + onQueryChange: (String) -> Unit, + modifier: Modifier = Modifier, +) { + val keyboard = LocalSoftwareKeyboardController.current + // surfaceContainerHighest — the picker sits on surfaceContainerHigh, so + // anything lower vanishes into it. + Surface( + color = MaterialTheme.colorScheme.surfaceContainerHighest, + shape = RoundedCornerShape(28.dp), + modifier = modifier.fillMaxWidth(), + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.padding(horizontal = 16.dp), + ) { + Icon( + imageVector = Icons.Default.Search, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + InlineTextField( + value = query, + onValueChange = onQueryChange, + placeholder = stringResource(R.string.event_edit_timezone_search), + capitalization = KeyboardCapitalization.None, + imeAction = ImeAction.Search, + onImeAction = { keyboard?.hide() }, + modifier = Modifier + .weight(1f) + .padding(horizontal = 12.dp, vertical = 14.dp), + ) + if (query.isNotEmpty()) { + IconButton(onClick = { onQueryChange("") }) { + Icon( + imageVector = Icons.Default.Close, + contentDescription = stringResource(R.string.search_clear), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } + } +} + /** A small primary-coloured group label, matching the settings screens. */ @Composable private fun SectionHeader(text: String) { diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/detail/EventDetailScreen.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/detail/EventDetailScreen.kt index 3d148fe..da5ccb8 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/detail/EventDetailScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/detail/EventDetailScreen.kt @@ -453,14 +453,41 @@ private fun EventDetailContent(state: EventDetailUiState.Success, modifier: Modi } // Time zone — only when the event is timed and pinned to a zone other - // than the device's, so cross-zone events read unambiguously. + // than the device's, so cross-zone events read unambiguously. The card + // above already answers "when is this for me"; this one answers "when + // was it set", which the zone's name alone never did — an 8 AM New York + // call showing as 2 PM here should still say 8 AM somewhere. foreignTimeZoneLabel(detail.eventTimezone, instance.isAllDay, locale)?.let { tzLabel -> Spacer(Modifier.height(gap)) DetailCard( icon = Icons.Default.Public, iconContentDescription = stringResource(R.string.event_detail_timezone), ) { - Text(text = tzLabel, style = MaterialTheme.typography.titleMedium) + val originalZone = detail.eventTimezone?.let { tz -> + runCatching { TimeZone.of(tz) }.getOrNull() + } + if (originalZone != null) { + // formatWhen puts the time range in the secondary half only + // for a same-day event; one spanning midnight — which a + // cross-zone event easily does — carries the whole span in + // the primary instead, so fall back to it rather than + // dropping the original time entirely. + val (primary, secondary) = formatWhen(instance, originalZone, locale) + Text( + text = stringResource( + R.string.event_detail_timezone_original, + secondary ?: primary, + originalZone.id.substringAfterLast('/').replace('_', ' '), + ), + style = MaterialTheme.typography.titleMedium, + ) + Spacer(Modifier.height(2.dp)) + } + Text( + text = tzLabel, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) } } diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/edit/EventEditScreen.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/edit/EventEditScreen.kt index 67e47a0..97993d2 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/edit/EventEditScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/edit/EventEditScreen.kt @@ -114,6 +114,7 @@ import de.jeanlucmakiola.calendula.domain.EventForm import de.jeanlucmakiola.calendula.domain.EventFormField import de.jeanlucmakiola.calendula.domain.formatGmtOffset import de.jeanlucmakiola.calendula.domain.timeZoneOptionOf +import de.jeanlucmakiola.calendula.domain.timesIn import de.jeanlucmakiola.calendula.domain.EventFormProblem import de.jeanlucmakiola.calendula.domain.RecurrenceEnd import de.jeanlucmakiola.calendula.domain.RecurrenceFreq @@ -676,6 +677,22 @@ private fun EventEditContent( color = MaterialTheme.colorScheme.error, ) } + // The rows above edit a pinned event in *its own* zone, which is the + // time it was set at — but that says nothing about when it lands for + // whoever is reading it. Spell the local equivalent out rather than + // leaving the user to do the offset arithmetic. Absent (null) unless + // the event is pinned somewhere other than here. + form.timesIn(TimeZone.currentSystemDefault())?.let { (localStart, localEnd) -> + Spacer(Modifier.height(2.dp)) + Text( + text = stringResource( + R.string.event_edit_timezone_local_time, + formatTimeRange(localStart, localEnd, locale), + ), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } } Spacer(Modifier.height(gap)) @@ -1986,6 +2003,21 @@ private fun EditCard( } } +/** + * "2:00 PM – 3:00 PM", honouring the user's 12/24-hour setting. Collapses to one + * time when both land on the same minute (an instant event shouldn't read as a + * range against itself). Dates are deliberately absent — the rows above carry + * them. + */ +@Composable +private fun formatTimeRange(start: LocalDateTime, end: LocalDateTime, locale: Locale): String { + val use24Hour = LocalUse24HourFormat.current + val timeFormat = remember(locale, use24Hour) { timeOfDayFormatter(use24Hour, locale) } + val from = timeFormat.format(start.time.toJavaLocalTime()) + val to = timeFormat.format(end.time.toJavaLocalTime()) + return if (from == to) from else "$from – $to" +} + /** * Borderless text input used inside the cards (and as the headline title). * Thin wrapper over the shared [InlineTextField] so the form and the rest of diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index eb35d31..16cfe3d 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -107,6 +107,13 @@ All time zones No time zone matches “%1$s” Use device zone + + %1$s your time + + %1$s in %2$s Color diff --git a/app/src/test/java/de/jeanlucmakiola/calendula/domain/EventFormTest.kt b/app/src/test/java/de/jeanlucmakiola/calendula/domain/EventFormTest.kt index a09a1b8..b2fb058 100644 --- a/app/src/test/java/de/jeanlucmakiola/calendula/domain/EventFormTest.kt +++ b/app/src/test/java/de/jeanlucmakiola/calendula/domain/EventFormTest.kt @@ -209,6 +209,67 @@ class EventFormTest { assertThat(prefilled.start).isEqualTo(LocalDateTime(LocalDate(2026, 6, 11), LocalTime(10, 0))) } + @Test + fun `timesIn converts a pinned event into the target zone`() { + val form = EventForm( + calendarId = 1L, + start = LocalDateTime(LocalDate(2026, 7, 17), LocalTime(8, 0)), + end = LocalDateTime(LocalDate(2026, 7, 17), LocalTime(9, 0)), + timezone = "America/New_York", + ) + val (start, end) = form.timesIn(berlin)!! + // 08:00 New York (EDT, UTC-4) is 14:00 Berlin (CEST, UTC+2). + assertThat(start).isEqualTo(LocalDateTime(LocalDate(2026, 7, 17), LocalTime(14, 0))) + assertThat(end).isEqualTo(LocalDateTime(LocalDate(2026, 7, 17), LocalTime(15, 0))) + } + + @Test + fun `timesIn crosses the date line when the offset pushes past midnight`() { + val form = EventForm( + calendarId = 1L, + start = LocalDateTime(LocalDate(2026, 7, 17), LocalTime(20, 0)), + end = LocalDateTime(LocalDate(2026, 7, 17), LocalTime(21, 0)), + timezone = "America/New_York", + ) + val (start, _) = form.timesIn(berlin)!! + // 20:00 New York is 02:00 the NEXT day in Berlin — the date has to move + // with it, not just the clock. + assertThat(start).isEqualTo(LocalDateTime(LocalDate(2026, 7, 18), LocalTime(2, 0))) + } + + @Test + fun `timesIn tracks each zone's own DST rather than a fixed offset`() { + // In January both are on standard time: 08:00 EST is 14:00 CET — the + // same six hours as July, but only because both shifted. Late March, + // when the US has sprung forward and Europe hasn't, the gap is five. + val march = EventForm( + calendarId = 1L, + start = LocalDateTime(LocalDate(2026, 3, 20), LocalTime(8, 0)), + end = LocalDateTime(LocalDate(2026, 3, 20), LocalTime(9, 0)), + timezone = "America/New_York", + ) + assertThat(march.timesIn(berlin)!!.first) + .isEqualTo(LocalDateTime(LocalDate(2026, 3, 20), LocalTime(13, 0))) + } + + @Test + fun `timesIn returns null when there is nothing to disambiguate`() { + val base = EventForm( + calendarId = 1L, + start = LocalDateTime(LocalDate(2026, 7, 17), LocalTime(8, 0)), + end = LocalDateTime(LocalDate(2026, 7, 17), LocalTime(9, 0)), + ) + // Unpinned: the form's times already are the local times. + assertThat(base.timesIn(berlin)).isNull() + // Pinned to the target itself: same thing. + assertThat(base.copy(timezone = "Europe/Berlin").timesIn(berlin)).isNull() + // All-day: date-anchored, so there's no zone conversion to show. + assertThat(base.copy(isAllDay = true, timezone = "America/New_York").timesIn(berlin)) + .isNull() + // Unparseable: can't convert, mustn't throw. + assertThat(base.copy(timezone = "Mars/Olympus_Mons").timesIn(berlin)).isNull() + } + @Test fun `toEditForm turns the exclusive all-day end into the last covered day`() { // 11th..13th = UTC midnights of the 11th and the (exclusive) 14th. From ca01f6e72945764a6eece8f4b099136054a7067a Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Fri, 17 Jul 2026 16:01:08 +0200 Subject: [PATCH 08/78] fix(detail): keep the zone card's hierarchy matching the When card MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The When card reads date-large, time-small-beneath. The zone card led with the original time at titleMedium and dropped the zone label to bodyMedium, inverting that — which made the foreign time the loudest thing on the screen and pulled attention off the local time the reader actually acts on. Put the label back on top and the original time small beneath it, so both cards read the same way and the original stays available without competing. The label already names the zone, so the range drops its "in New York" tail (and the string with it). Co-Authored-By: Claude Opus 4.8 (1M context) --- .../calendula/ui/detail/EventDetailScreen.kt | 32 +++++++++---------- app/src/main/res/values/strings.xml | 3 -- 2 files changed, 15 insertions(+), 20 deletions(-) diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/detail/EventDetailScreen.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/detail/EventDetailScreen.kt index da5ccb8..61fad9f 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/detail/EventDetailScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/detail/EventDetailScreen.kt @@ -453,16 +453,20 @@ private fun EventDetailContent(state: EventDetailUiState.Success, modifier: Modi } // Time zone — only when the event is timed and pinned to a zone other - // than the device's, so cross-zone events read unambiguously. The card - // above already answers "when is this for me"; this one answers "when - // was it set", which the zone's name alone never did — an 8 AM New York - // call showing as 2 PM here should still say 8 AM somewhere. + // than the device's, so cross-zone events read unambiguously. It answers + // "when was it set", which the zone's name alone never did: an 8 AM New + // York call showing as 2 PM here should still say 8 AM somewhere. + // + // Same hierarchy as the When card above — the label carries the card, + // the time sits small beneath it. The local time is the one the reader + // acts on, so the original must stay quieter than it, not compete. foreignTimeZoneLabel(detail.eventTimezone, instance.isAllDay, locale)?.let { tzLabel -> Spacer(Modifier.height(gap)) DetailCard( icon = Icons.Default.Public, iconContentDescription = stringResource(R.string.event_detail_timezone), ) { + Text(text = tzLabel, style = MaterialTheme.typography.titleMedium) val originalZone = detail.eventTimezone?.let { tz -> runCatching { TimeZone.of(tz) }.getOrNull() } @@ -473,21 +477,15 @@ private fun EventDetailContent(state: EventDetailUiState.Success, modifier: Modi // the primary instead, so fall back to it rather than // dropping the original time entirely. val (primary, secondary) = formatWhen(instance, originalZone, locale) - Text( - text = stringResource( - R.string.event_detail_timezone_original, - secondary ?: primary, - originalZone.id.substringAfterLast('/').replace('_', ' '), - ), - style = MaterialTheme.typography.titleMedium, - ) Spacer(Modifier.height(2.dp)) + Text( + // The label above already names the zone, so the range + // needs no "in New York" tail to be unambiguous here. + text = secondary ?: primary, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) } - Text( - text = tzLabel, - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) } } diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 16cfe3d..eb03dd9 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -111,9 +111,6 @@ the same event expressed where the user actually is. %1$s is a time range, e.g. "2:00 PM – 3:00 PM". --> %1$s your time - - %1$s in %2$s Color From 01ac61185bf1c3e4995fe9a05b843549e05f5c4f Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Sun, 19 Jul 2026 10:48:21 +0200 Subject: [PATCH 09/78] fix(timezone): show the IANA id + abbreviation, not the long localized name MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit "Central European Time (Europe/Berlin)" is too wide — it made the zone field wrap and stretch. Lead with the id ("Europe/Berlin") and follow it with the abbreviation instead: "CEST · GMT+02:00" in the picker and edit card, "CET · 8:00 AM – 9:00 AM" on the detail card (abbreviation + the event's own-zone time). The abbreviation is resolved DST-aware at the same instant as the offset (CET vs CEST) via "zzz"; zones with no named abbreviation fall back to a "GMT+05:30" form, and zoneDescriptor drops the separate offset in that case so it isn't stated twice. The long localized name is kept on the option for search only — typing "pacific" still works — but no longer shown. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../calendula/domain/TimeZoneCatalog.kt | 61 ++++++++++------ .../calendula/ui/common/TimeZonePicker.kt | 10 +-- .../calendula/ui/detail/EventDetailScreen.kt | 71 ++++++++++--------- .../calendula/ui/edit/EventEditScreen.kt | 7 +- .../calendula/domain/TimeZoneCatalogTest.kt | 39 ++++++++++ 5 files changed, 124 insertions(+), 64 deletions(-) diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/domain/TimeZoneCatalog.kt b/app/src/main/java/de/jeanlucmakiola/calendula/domain/TimeZoneCatalog.kt index ca08fc0..b67af5d 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/domain/TimeZoneCatalog.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/domain/TimeZoneCatalog.kt @@ -3,20 +3,31 @@ package de.jeanlucmakiola.calendula.domain import java.text.Normalizer import java.time.Instant import java.time.ZoneId +import java.time.ZonedDateTime +import java.time.format.DateTimeFormatter import java.time.format.TextStyle import java.util.Locale /** - * One selectable zone, resolved for display: [id] is the IANA id we store in - * `EVENT_TIMEZONE`, [displayName] its localized name, and [offsetMinutes] its - * offset *at a given instant* — zones shift with DST, so an offset is only - * meaningful next to the moment it was resolved for. + * One selectable zone, resolved for display at a given instant. [id] is the IANA + * id we store in `EVENT_TIMEZONE` and show as the primary label (via [label]); + * [shortName] its abbreviation ("CET", "WET", "UTC", or a "GMT+05:30" fallback); + * [offsetMinutes] its offset. Both the abbreviation and the offset shift with + * DST, so they're only meaningful next to the moment they were resolved for. + * + * [displayName] is the long localized name ("Central European Time"). It's kept + * for search only — matching on it lets someone type "pacific" — and is *not* + * displayed: spelled out next to the id it made the field too wide to fit. */ data class TimeZoneOption( val id: String, val displayName: String, + val shortName: String, val offsetMinutes: Int, ) { + /** The id as the primary label, underscores undone ("America/New York"). */ + val label: String get() = id.replace('_', ' ') + /** The trailing segment of the id ("Europe/Berlin" -> "Berlin"), underscores undone. */ val city: String get() = id.substringAfterLast('/').replace('_', ' ') @@ -24,10 +35,20 @@ data class TimeZoneOption( val region: String get() = id.substringBeforeLast('/', missingDelimiterValue = "") } +private fun optionFor(zone: ZoneId, locale: Locale, at: Instant) = TimeZoneOption( + id = zone.id, + displayName = zone.getDisplayName(TextStyle.FULL, locale), + // "zzz" resolves the DST-correct abbreviation at [at] (CET vs CEST); zones + // with no named abbreviation fall back to a "GMT+05:30" form, which is + // exactly what we'd want to show them as anyway. + shortName = ZonedDateTime.ofInstant(at, zone) + .format(DateTimeFormatter.ofPattern("zzz", locale)), + offsetMinutes = zone.rules.getOffset(at).totalSeconds / 60, +) + /** - * Every zone the JVM knows, localized and resolved at [at]. This is ~600 - * entries, so build it once and filter the result rather than rebuilding per - * keystroke. + * Every zone the JVM knows, resolved at [at]. This is ~600 entries, so build it + * once and filter the result rather than rebuilding per keystroke. * * Bare three-letter ids ("EST", "CST6CDT") and the legacy SystemV tree are * dropped: they're aliases the tz database keeps for compatibility, they'd @@ -40,14 +61,7 @@ fun timeZoneOptions( ): List = ZoneId.getAvailableZoneIds() .asSequence() .filter { it.contains('/') && !it.startsWith("SystemV/") } - .map { id -> - val zone = ZoneId.of(id) - TimeZoneOption( - id = id, - displayName = zone.getDisplayName(TextStyle.FULL, locale), - offsetMinutes = zone.rules.getOffset(at).totalSeconds / 60, - ) - } + .map { optionFor(ZoneId.of(it), locale, at) } .sortedWith(compareBy({ it.region }, { it.city })) .toList() @@ -62,11 +76,18 @@ fun timeZoneOptionOf( at: Instant = Instant.now(), ): TimeZoneOption? { val zone = runCatching { ZoneId.of(id) }.getOrNull() ?: return null - return TimeZoneOption( - id = id, - displayName = zone.getDisplayName(TextStyle.FULL, locale), - offsetMinutes = zone.rules.getOffset(at).totalSeconds / 60, - ) + return optionFor(zone, locale, at) +} + +/** + * The short descriptor shown beneath the id, e.g. "CET · GMT+01:00". When the + * abbreviation is itself offset-shaped ("UTC", "GMT+05:30") the offset would + * only repeat it, so the abbreviation stands alone. + */ +fun zoneDescriptor(option: TimeZoneOption): String { + val abbrev = option.shortName + val offsetShaped = abbrev == "UTC" || abbrev.startsWith("GMT") + return if (offsetShaped) abbrev else "$abbrev · ${formatGmtOffset(option.offsetMinutes)}" } /** diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/TimeZonePicker.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/TimeZonePicker.kt index 4e1579d..ed74451 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/TimeZonePicker.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/TimeZonePicker.kt @@ -35,8 +35,8 @@ import androidx.compose.ui.unit.dp import de.jeanlucmakiola.calendula.R import de.jeanlucmakiola.calendula.domain.TimeZoneOption import de.jeanlucmakiola.calendula.domain.filterTimeZones -import de.jeanlucmakiola.calendula.domain.formatGmtOffset import de.jeanlucmakiola.calendula.domain.timeZoneOptions +import de.jeanlucmakiola.calendula.domain.zoneDescriptor import de.jeanlucmakiola.floret.components.FullScreenPicker import de.jeanlucmakiola.floret.components.GroupedRow import de.jeanlucmakiola.floret.components.InlineTextField @@ -240,8 +240,8 @@ private fun ZoneRow( onClick: () -> Unit, ) { GroupedRow( - title = zone.city, - summary = "${zone.displayName} · ${formatGmtOffset(zone.offsetMinutes)}", + title = zone.label, + summary = zoneDescriptor(zone), position = position, selected = selected, trailing = if (selected) { @@ -253,8 +253,8 @@ private fun ZoneRow( ) } -/** "Central European Time · GMT+02:00" for [zoneId], or the bare id if unknown. */ +/** "CET · GMT+01:00" for [zoneId], or the bare id if the catalogue lacks it. */ private fun zoneSummary(zoneId: String, allZones: List): String = allZones.firstOrNull { it.id == zoneId } - ?.let { "${it.displayName} · ${formatGmtOffset(it.offsetMinutes)}" } + ?.let { zoneDescriptor(it) } ?: zoneId diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/detail/EventDetailScreen.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/detail/EventDetailScreen.kt index 61fad9f..83c7032 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/detail/EventDetailScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/detail/EventDetailScreen.kt @@ -93,6 +93,9 @@ import de.jeanlucmakiola.calendula.domain.EventInstance import de.jeanlucmakiola.calendula.domain.EventStatus import de.jeanlucmakiola.calendula.domain.RecurringWriteScope import de.jeanlucmakiola.calendula.domain.Reminder +import de.jeanlucmakiola.calendula.domain.TimeZoneOption +import de.jeanlucmakiola.calendula.domain.timeZoneOptionOf +import de.jeanlucmakiola.calendula.domain.zoneDescriptor import de.jeanlucmakiola.floret.identity.predictiveBack import de.jeanlucmakiola.calendula.ui.common.CalendarFailure import de.jeanlucmakiola.calendula.ui.common.LocalSoftenColors @@ -108,7 +111,6 @@ import kotlinx.datetime.TimeZone import java.time.ZoneId import java.time.format.DateTimeFormatter import java.time.format.FormatStyle -import java.time.format.TextStyle as JavaTextStyle import java.util.Locale import kotlin.time.Duration.Companion.seconds import kotlin.time.Instant @@ -460,32 +462,37 @@ private fun EventDetailContent(state: EventDetailUiState.Success, modifier: Modi // Same hierarchy as the When card above — the label carries the card, // the time sits small beneath it. The local time is the one the reader // acts on, so the original must stay quieter than it, not compete. - foreignTimeZoneLabel(detail.eventTimezone, instance.isAllDay, locale)?.let { tzLabel -> + val foreignZone = remember(detail.eventTimezone, instance.isAllDay, locale) { + foreignTimeZone(detail.eventTimezone, instance.isAllDay, locale) + } + foreignZone?.let { zoneOption -> Spacer(Modifier.height(gap)) DetailCard( icon = Icons.Default.Public, iconContentDescription = stringResource(R.string.event_detail_timezone), ) { - Text(text = tzLabel, style = MaterialTheme.typography.titleMedium) - val originalZone = detail.eventTimezone?.let { tz -> - runCatching { TimeZone.of(tz) }.getOrNull() - } - if (originalZone != null) { - // formatWhen puts the time range in the secondary half only - // for a same-day event; one spanning midnight — which a - // cross-zone event easily does — carries the whole span in - // the primary instead, so fall back to it rather than - // dropping the original time entirely. - val (primary, secondary) = formatWhen(instance, originalZone, locale) - Spacer(Modifier.height(2.dp)) - Text( - // The label above already names the zone, so the range - // needs no "in New York" tail to be unambiguous here. - text = secondary ?: primary, - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } + // The id is the primary label ("Europe/Berlin"); the long + // localized name spelled next to it made the field too wide. + Text(text = zoneOption.label, style = MaterialTheme.typography.titleMedium) + // Beneath, small: the abbreviation plus the event's own-zone time, + // e.g. "CET · 8:00 AM – 9:00 AM". formatWhen puts the range in the + // secondary half only for a same-day event; one spanning midnight + // — which a cross-zone event easily does — carries the whole span + // in the primary instead, so fall back to it. If the zone can't be + // read back at all, drop to just the abbreviation + offset. + val originalTime = runCatching { TimeZone.of(zoneOption.id) }.getOrNull() + ?.let { formatWhen(instance, it, locale) } + ?.let { (primary, secondary) -> secondary ?: primary } + Spacer(Modifier.height(2.dp)) + Text( + text = if (originalTime != null) { + "${zoneOption.shortName} · $originalTime" + } else { + zoneDescriptor(zoneOption) + }, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) } } @@ -777,21 +784,15 @@ private fun attendeeRoleLabel(attendee: Attendee): Int? = when { private fun reminderLeadText(reminder: Reminder): String = reminderLeadTimeLabel(reminder.minutes) /** - * A localized label for [tz] (e.g. "Central European Time (Europe/Berlin)"), - * but only when the event is timed and pinned to a zone different from the - * device's. Returns null when there's nothing worth showing. + * [tz] resolved as a [TimeZoneOption], but only when the event is timed and + * pinned to a zone different from the device's — the cases where showing it + * removes ambiguity. Null otherwise (all-day, device-zone, blank, or an id the + * tz database doesn't know). */ -private fun foreignTimeZoneLabel(tz: String?, isAllDay: Boolean, locale: Locale): String? { +private fun foreignTimeZone(tz: String?, isAllDay: Boolean, locale: Locale): TimeZoneOption? { if (isAllDay || tz.isNullOrBlank()) return null - val deviceZone = ZoneId.systemDefault().id - if (tz == deviceZone) return null - return try { - val zone = ZoneId.of(tz) - val name = zone.getDisplayName(JavaTextStyle.FULL, locale) - if (name == tz) tz else "$name ($tz)" - } catch (e: Exception) { - tz - } + if (tz == ZoneId.systemDefault().id) return null + return timeZoneOptionOf(tz, locale) } /** Wrap http(s) URLs in [text] as tappable links tinted [linkColor]. */ diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/edit/EventEditScreen.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/edit/EventEditScreen.kt index 97993d2..e2f3a33 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/edit/EventEditScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/edit/EventEditScreen.kt @@ -112,7 +112,7 @@ import de.jeanlucmakiola.calendula.domain.EventAttendee import de.jeanlucmakiola.calendula.domain.EventColorOption import de.jeanlucmakiola.calendula.domain.EventForm import de.jeanlucmakiola.calendula.domain.EventFormField -import de.jeanlucmakiola.calendula.domain.formatGmtOffset +import de.jeanlucmakiola.calendula.domain.zoneDescriptor import de.jeanlucmakiola.calendula.domain.timeZoneOptionOf import de.jeanlucmakiola.calendula.domain.timesIn import de.jeanlucmakiola.calendula.domain.EventFormProblem @@ -745,13 +745,12 @@ private fun EventEditContent( } Column(modifier = Modifier.weight(1f)) { Text( - text = pinned?.city + text = pinned?.label ?: stringResource(R.string.event_edit_timezone_device), style = MaterialTheme.typography.titleMedium, ) Text( - text = pinned - ?.let { "${it.displayName} · ${formatGmtOffset(it.offsetMinutes)}" } + text = pinned?.let { zoneDescriptor(it) } ?: stringResource(R.string.event_edit_timezone_device_summary), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, diff --git a/app/src/test/java/de/jeanlucmakiola/calendula/domain/TimeZoneCatalogTest.kt b/app/src/test/java/de/jeanlucmakiola/calendula/domain/TimeZoneCatalogTest.kt index fbecfc5..1ef2512 100644 --- a/app/src/test/java/de/jeanlucmakiola/calendula/domain/TimeZoneCatalogTest.kt +++ b/app/src/test/java/de/jeanlucmakiola/calendula/domain/TimeZoneCatalogTest.kt @@ -30,6 +30,45 @@ class TimeZoneCatalogTest { assertThat(ny.region).isEqualTo("America") } + @Test + fun `label is the id with underscores undone`() { + assertThat(zones.first { it.id == "America/New_York" }.label) + .isEqualTo("America/New York") + assertThat(zones.first { it.id == "Europe/Berlin" }.label).isEqualTo("Europe/Berlin") + } + + @Test + fun `resolved abbreviation is compact, not the long name`() { + // Whatever CLDR gives ("CET"/"CEST" or a "GMT+.." fallback), it must be + // short and space-free — that's the whole point of showing it instead of + // "Central European Time". + val berlin = zones.first { it.id == "Europe/Berlin" } + assertThat(berlin.shortName).doesNotContain(" ") + assertThat(berlin.shortName.length).isLessThan(berlin.displayName.length) + } + + @Test + fun `descriptor pairs a named abbreviation with the offset`() { + val option = TimeZoneOption( + id = "Europe/Berlin", + displayName = "Central European Time", + shortName = "CET", + offsetMinutes = 60, + ) + assertThat(zoneDescriptor(option)).isEqualTo("CET · GMT+01:00") + } + + @Test + fun `descriptor drops the offset when the abbreviation already is one`() { + // Repeating the offset after an offset-shaped abbreviation would just say + // the same thing twice. + fun descriptorFor(shortName: String, offset: Int) = zoneDescriptor( + TimeZoneOption(id = "X/Y", displayName = "", shortName = shortName, offsetMinutes = offset), + ) + assertThat(descriptorFor("UTC", 0)).isEqualTo("UTC") + assertThat(descriptorFor("GMT+05:30", 330)).isEqualTo("GMT+05:30") + } + @Test fun `offset is resolved at the given instant, not the current one`() { val berlin = zones.first { it.id == "Europe/Berlin" } From 629896b74d4bf4b52622288efa75272d1b183a5d Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Sun, 19 Jul 2026 11:01:25 +0200 Subject: [PATCH 10/78] fix(timezone): resolve the abbreviation via java.util, not java.time MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The abbreviation came from java.time's "zzz" formatter, which on Android has no short specific-zone names and silently degrades every zone to a "GMT-4" form — so on device New York showed "GMT-04:00" instead of "EDT". Desktop couldn't catch it: there "zzz" and java.util.TimeZone agree, and that agreement is the whole trap. Resolve through java.util.TimeZone.getDisplayName(inDst, SHORT, locale) instead — ICU-backed, so it returns the real abbreviation on both Android and the JVM. Zones with no named abbreviation still fall back to a GMT form, which zoneDescriptor already collapses to a single token. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../calendula/domain/TimeZoneCatalog.kt | 30 +++++++++++-------- 1 file changed, 18 insertions(+), 12 deletions(-) diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/domain/TimeZoneCatalog.kt b/app/src/main/java/de/jeanlucmakiola/calendula/domain/TimeZoneCatalog.kt index b67af5d..97aa834 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/domain/TimeZoneCatalog.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/domain/TimeZoneCatalog.kt @@ -3,8 +3,6 @@ package de.jeanlucmakiola.calendula.domain import java.text.Normalizer import java.time.Instant import java.time.ZoneId -import java.time.ZonedDateTime -import java.time.format.DateTimeFormatter import java.time.format.TextStyle import java.util.Locale @@ -35,16 +33,24 @@ data class TimeZoneOption( val region: String get() = id.substringBeforeLast('/', missingDelimiterValue = "") } -private fun optionFor(zone: ZoneId, locale: Locale, at: Instant) = TimeZoneOption( - id = zone.id, - displayName = zone.getDisplayName(TextStyle.FULL, locale), - // "zzz" resolves the DST-correct abbreviation at [at] (CET vs CEST); zones - // with no named abbreviation fall back to a "GMT+05:30" form, which is - // exactly what we'd want to show them as anyway. - shortName = ZonedDateTime.ofInstant(at, zone) - .format(DateTimeFormatter.ofPattern("zzz", locale)), - offsetMinutes = zone.rules.getOffset(at).totalSeconds / 60, -) +private fun optionFor(zone: ZoneId, locale: Locale, at: Instant): TimeZoneOption { + val offsetSeconds = zone.rules.getOffset(at).totalSeconds + return TimeZoneOption( + id = zone.id, + displayName = zone.getDisplayName(TextStyle.FULL, locale), + // The abbreviation ("EDT"/"CEST", DST-correct at [at]). Resolved through + // java.util.TimeZone, NOT java.time's "zzz" formatter: on Android the + // latter has no short specific-zone names and silently degrades every + // zone to a "GMT-4" form (it agrees with java.util only on desktop), so + // "zzz" cost us the real abbreviation on the one platform that ships. + // java.util is ICU-backed and returns the abbreviation on both. Zones + // with genuinely no named abbreviation still fall back to a GMT form, + // which is what we'd show anyway. + shortName = java.util.TimeZone.getTimeZone(zone.id) + .getDisplayName(zone.rules.isDaylightSavings(at), java.util.TimeZone.SHORT, locale), + offsetMinutes = offsetSeconds / 60, + ) +} /** * Every zone the JVM knows, resolved at [at]. This is ~600 entries, so build it From 734bcae02d0bc053850b1829ef606e3248ddcde7 Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Sun, 19 Jul 2026 11:15:48 +0200 Subject: [PATCH 11/78] fix(timezone): resolve the abbreviation in the zone's own region MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit java.util gave abbreviations only for zones in the display locale's region: an en-DE phone saw "CEST" for Berlin but "GMT-4" for New York, and an en-US phone the reverse — ICU only surfaces the short names commonly used where the reader is. Verified on-device (en-DE): US zones fell back to the offset, EU ones resolved. Ask ICU in the display language but the *zone's* region instead — New York in en-US, Berlin in en-DE — using android.icu's zone→region map. On-device that lights up EDT/PDT/CDT, plus BST, AEST, IST, JST that showed only the offset before. Where a region still has no name (Athens in en-GR) the device locale sometimes does, so fall back to it, then to the offset. The region lookup needs android.icu, which domain/ can't import, so it's injected: timeZoneOptions takes a regionOf lambda (default none, keeping the module pure and JVM-tested), and the UI passes icuTimeZoneRegion. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../calendula/domain/TimeZoneCatalog.kt | 90 +++++++++++++------ .../calendula/ui/common/TimeZonePicker.kt | 2 +- .../calendula/ui/common/TimeZoneRegion.kt | 16 ++++ .../calendula/ui/detail/EventDetailScreen.kt | 3 +- .../calendula/ui/edit/EventEditScreen.kt | 3 +- 5 files changed, 86 insertions(+), 28 deletions(-) create mode 100644 app/src/main/java/de/jeanlucmakiola/calendula/ui/common/TimeZoneRegion.kt diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/domain/TimeZoneCatalog.kt b/app/src/main/java/de/jeanlucmakiola/calendula/domain/TimeZoneCatalog.kt index 97aa834..e688a66 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/domain/TimeZoneCatalog.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/domain/TimeZoneCatalog.kt @@ -33,28 +33,63 @@ data class TimeZoneOption( val region: String get() = id.substringBeforeLast('/', missingDelimiterValue = "") } -private fun optionFor(zone: ZoneId, locale: Locale, at: Instant): TimeZoneOption { - val offsetSeconds = zone.rules.getOffset(at).totalSeconds - return TimeZoneOption( - id = zone.id, - displayName = zone.getDisplayName(TextStyle.FULL, locale), - // The abbreviation ("EDT"/"CEST", DST-correct at [at]). Resolved through - // java.util.TimeZone, NOT java.time's "zzz" formatter: on Android the - // latter has no short specific-zone names and silently degrades every - // zone to a "GMT-4" form (it agrees with java.util only on desktop), so - // "zzz" cost us the real abbreviation on the one platform that ships. - // java.util is ICU-backed and returns the abbreviation on both. Zones - // with genuinely no named abbreviation still fall back to a GMT form, - // which is what we'd show anyway. - shortName = java.util.TimeZone.getTimeZone(zone.id) - .getDisplayName(zone.rules.isDaylightSavings(at), java.util.TimeZone.SHORT, locale), - offsetMinutes = offsetSeconds / 60, - ) +private fun optionFor( + zone: ZoneId, + locale: Locale, + at: Instant, + regionOf: (String) -> String?, +): TimeZoneOption = TimeZoneOption( + id = zone.id, + displayName = zone.getDisplayName(TextStyle.FULL, locale), + shortName = resolveAbbreviation(zone.id, zone.rules.isDaylightSavings(at), locale, regionOf), + offsetMinutes = zone.rules.getOffset(at).totalSeconds / 60, +) + +/** + * The zone abbreviation ("EDT", "CEST"), DST-correct at the resolved instant, or + * a "GMT+05:30" form when no name exists. + * + * Two things make this fiddlier than it looks. First, it goes through + * java.util.TimeZone, NOT java.time's "zzz" formatter: on Android the latter has + * no short specific-zone names and degrades every zone to a "GMT-4" form (it + * agrees with java.util only on desktop, which is why desktop can't catch it). + * Second, ICU only surfaces the short name *commonly used in the display + * locale's region* — a German-region English phone (en-DE) is shown "CEST" but + * not "EDT", and an en-US phone the reverse. So ask in the display *language* + * but the *zone's* region ("America/New_York" in en-US, "Europe/Berlin" in + * en-DE) via [regionOf]. That covers almost everything; where a region still + * yields no name (Athens in en-GR) the plain device locale sometimes does, so + * try that next; failing both, the caller shows the offset. + * + * [regionOf] maps an IANA id to an ISO 3166 region and is injected because it + * needs `android.icu`, which this pure-JVM module can't import. The default + * (no region) leaves only the device-locale path — which is all a JVM test has + * anyway, and enough there since desktop ICU isn't region-gated the same way. + */ +private fun resolveAbbreviation( + id: String, + dst: Boolean, + locale: Locale, + regionOf: (String) -> String?, +): String { + fun shortIn(loc: Locale): String = + java.util.TimeZone.getTimeZone(id).getDisplayName(dst, java.util.TimeZone.SHORT, loc) + val regional = regionOf(id) + ?.takeIf { it.length == 2 } + ?.let { shortIn(Locale(locale.language, it)) } + if (regional != null && !regional.looksLikeOffset()) return regional + // Either no region, or the region has no name for this zone: the device + // locale is the next-best shot, and the offset the last resort. + return shortIn(locale) } +/** True for the offset-style names ICU returns when a zone has no abbreviation. */ +private fun String.looksLikeOffset(): Boolean = this == "UTC" || startsWith("GMT") + /** * Every zone the JVM knows, resolved at [at]. This is ~600 entries, so build it - * once and filter the result rather than rebuilding per keystroke. + * once and filter the result rather than rebuilding per keystroke. [regionOf] + * (see [resolveAbbreviation]) supplies the zone's region for the abbreviation. * * Bare three-letter ids ("EST", "CST6CDT") and the legacy SystemV tree are * dropped: they're aliases the tz database keeps for compatibility, they'd @@ -64,10 +99,11 @@ private fun optionFor(zone: ZoneId, locale: Locale, at: Instant): TimeZoneOption fun timeZoneOptions( locale: Locale = Locale.getDefault(), at: Instant = Instant.now(), + regionOf: (String) -> String? = { null }, ): List = ZoneId.getAvailableZoneIds() .asSequence() .filter { it.contains('/') && !it.startsWith("SystemV/") } - .map { optionFor(ZoneId.of(it), locale, at) } + .map { optionFor(ZoneId.of(it), locale, at, regionOf) } .sortedWith(compareBy({ it.region }, { it.city })) .toList() @@ -80,20 +116,24 @@ fun timeZoneOptionOf( id: String, locale: Locale = Locale.getDefault(), at: Instant = Instant.now(), + regionOf: (String) -> String? = { null }, ): TimeZoneOption? { val zone = runCatching { ZoneId.of(id) }.getOrNull() ?: return null - return optionFor(zone, locale, at) + return optionFor(zone, locale, at, regionOf) } /** - * The short descriptor shown beneath the id, e.g. "CET · GMT+01:00". When the - * abbreviation is itself offset-shaped ("UTC", "GMT+05:30") the offset would - * only repeat it, so the abbreviation stands alone. + * The short descriptor shown beneath the id, e.g. "CET · GMT+01:00". "UTC" reads + * fine alone; any other offset-shaped name is normalised to our own GMT form so + * the offset isn't stated twice in ICU's spelling and ours. */ fun zoneDescriptor(option: TimeZoneOption): String { val abbrev = option.shortName - val offsetShaped = abbrev == "UTC" || abbrev.startsWith("GMT") - return if (offsetShaped) abbrev else "$abbrev · ${formatGmtOffset(option.offsetMinutes)}" + return when { + abbrev == "UTC" -> "UTC" + abbrev.startsWith("GMT") -> formatGmtOffset(option.offsetMinutes) + else -> "$abbrev · ${formatGmtOffset(option.offsetMinutes)}" + } } /** diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/TimeZonePicker.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/TimeZonePicker.kt index ed74451..fc5d472 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/TimeZonePicker.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/TimeZonePicker.kt @@ -67,7 +67,7 @@ fun TimeZonePickerDialog( // ~600 zones, each resolving a localized name and a DST-aware offset: build // once per open, then filter the result per keystroke. val locale = currentLocale() - val allZones = remember(locale) { timeZoneOptions(locale) } + val allZones = remember(locale) { timeZoneOptions(locale, regionOf = ::icuTimeZoneRegion) } val matches = remember(allZones, query) { filterTimeZones(allZones, query) } val recentZones = remember(allZones, recents) { recents.mapNotNull { id -> allZones.firstOrNull { it.id == id } } diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/TimeZoneRegion.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/TimeZoneRegion.kt new file mode 100644 index 0000000..d8e5769 --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/TimeZoneRegion.kt @@ -0,0 +1,16 @@ +package de.jeanlucmakiola.calendula.ui.common + +import android.icu.util.TimeZone as IcuTimeZone + +/** + * The ISO 3166 region an IANA zone belongs to ("America/New_York" -> "US"), or + * null when ICU has none or maps it to the multi-country "001" ("Etc/UTC"). + * + * This is the `android.icu`-backed bridge the pure-JVM + * [de.jeanlucmakiola.calendula.domain.timeZoneOptions] takes as `regionOf`, so + * it can ask ICU for a zone's abbreviation in that zone's own region — the only + * region for which ICU will surface it (see `resolveAbbreviation`). Lives here, + * not in `domain/`, precisely because it needs an Android import. + */ +fun icuTimeZoneRegion(id: String): String? = + runCatching { IcuTimeZone.getRegion(id) }.getOrNull()?.takeIf { it.length == 2 } diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/detail/EventDetailScreen.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/detail/EventDetailScreen.kt index 83c7032..639f7fd 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/detail/EventDetailScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/detail/EventDetailScreen.kt @@ -98,6 +98,7 @@ import de.jeanlucmakiola.calendula.domain.timeZoneOptionOf import de.jeanlucmakiola.calendula.domain.zoneDescriptor import de.jeanlucmakiola.floret.identity.predictiveBack import de.jeanlucmakiola.calendula.ui.common.CalendarFailure +import de.jeanlucmakiola.calendula.ui.common.icuTimeZoneRegion import de.jeanlucmakiola.calendula.ui.common.LocalSoftenColors import de.jeanlucmakiola.calendula.ui.common.eventFill import de.jeanlucmakiola.floret.components.OptionCard @@ -792,7 +793,7 @@ private fun reminderLeadText(reminder: Reminder): String = reminderLeadTimeLabel private fun foreignTimeZone(tz: String?, isAllDay: Boolean, locale: Locale): TimeZoneOption? { if (isAllDay || tz.isNullOrBlank()) return null if (tz == ZoneId.systemDefault().id) return null - return timeZoneOptionOf(tz, locale) + return timeZoneOptionOf(tz, locale, regionOf = ::icuTimeZoneRegion) } /** Wrap http(s) URLs in [text] as tappable links tinted [linkColor]. */ diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/edit/EventEditScreen.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/edit/EventEditScreen.kt index e2f3a33..3f5d920 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/edit/EventEditScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/edit/EventEditScreen.kt @@ -131,6 +131,7 @@ import de.jeanlucmakiola.calendula.ui.common.CalendarPickerGroups import de.jeanlucmakiola.calendula.ui.common.CalendarDatePickerDialog import de.jeanlucmakiola.calendula.ui.common.ColorSwatchRow import de.jeanlucmakiola.calendula.ui.common.TimeZonePickerDialog +import de.jeanlucmakiola.calendula.ui.common.icuTimeZoneRegion import de.jeanlucmakiola.calendula.ui.common.LocalSoftenColors import de.jeanlucmakiola.calendula.ui.common.eventFill import de.jeanlucmakiola.floret.components.DialogAmountField @@ -741,7 +742,7 @@ private fun EventEditContent( modifier = Modifier.fillMaxWidth(), ) { val pinned = remember(form.timezone, locale) { - form.timezone?.let { timeZoneOptionOf(it, locale) } + form.timezone?.let { timeZoneOptionOf(it, locale, regionOf = ::icuTimeZoneRegion) } } Column(modifier = Modifier.weight(1f)) { Text( From 7ed1d89b66743b05f31fac8243553a21881b8185 Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Sun, 19 Jul 2026 11:16:08 +0200 Subject: [PATCH 12/78] fix(agenda): list multi-day events on every day they span MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit groupAgendaDays keyed each instance by its start day alone, so a multi-day event surfaced only on its first day and vanished from the rest of its span in both the Agenda screen and the agenda widget. Expand each instance across every day from its start (clamped to the anchor for ongoing events) through its last occupied day, bounded by the visible window end. An event ending exactly at midnight — including the exclusive next-midnight all-day events end at — does not reach that boundary day. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../calendula/ui/agenda/AgendaUiState.kt | 50 +++++--- .../calendula/ui/agenda/AgendaViewModel.kt | 2 +- .../calendula/widget/WidgetData.kt | 3 +- .../ui/agenda/GroupAgendaDaysTest.kt | 119 ++++++++++++++++++ 4 files changed, 156 insertions(+), 18 deletions(-) create mode 100644 app/src/test/java/de/jeanlucmakiola/calendula/ui/agenda/GroupAgendaDaysTest.kt 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 8e1b5dd..6419101 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 @@ -2,9 +2,12 @@ package de.jeanlucmakiola.calendula.ui.agenda import de.jeanlucmakiola.calendula.domain.EventInstance import de.jeanlucmakiola.calendula.domain.FailureReason +import kotlinx.datetime.DateTimeUnit import kotlinx.datetime.LocalDate import kotlinx.datetime.TimeZone +import kotlinx.datetime.plus import kotlinx.datetime.toLocalDateTime +import kotlin.time.Duration.Companion.milliseconds /** One calendar day with at least one event, for the agenda list. */ data class AgendaDay( @@ -15,31 +18,46 @@ data class AgendaDay( /** * 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. + * actually carry events). A multi-day event surfaces on *every* day it spans, + * not just its first — clamped to [[anchor], [windowEnd]] so an event that began + * before the window (ongoing) still lists from the anchor day, and one running + * past the window stops at the last visible day. 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, + windowEnd: 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 }, - ), - ) +): List { + val byDay = sortedMapOf>() + for (instance in instances) { + val firstDay = instance.start.toLocalDateTime(zone).date.coerceAtLeast(anchor) + // The last day the event actually occupies. An event ending exactly at + // midnight (all-day events end at the exclusive next-midnight) does not + // reach into that boundary day, so resolve the instant just before [end]. + val lastInstant = if (instance.end > instance.start) instance.end - 1.milliseconds else instance.start + val lastDay = lastInstant.toLocalDateTime(zone).date.coerceAtMost(windowEnd) + var day = firstDay + while (day <= lastDay) { + byDay.getOrPut(day) { mutableListOf() }.add(instance) + day = day.plus(1, DateTimeUnit.DAY) } + } + return byDay.map { (date, dayEvents) -> + AgendaDay( + date = date, + events = dayEvents.sortedWith( + compareByDescending { it.isAllDay } + .thenBy { it.start } + .thenBy { it.title }, + ), + ) + } +} /** * Ensure [today] surfaces as the first agenda day even when it carries no 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 0f8ce1a..924f6b8 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 @@ -159,11 +159,11 @@ class AgendaViewModel @Inject constructor( return AgendaUiState.Failure(FailureReason.NoCalendarsConfigured) } val anchor = params.anchor - val days = groupAgendaDays(anchor, instances, zone) val rangeEnd = anchor.plus( params.range.dayCount(anchor, params.weekStart) - 1, DateTimeUnit.DAY, ) + val days = groupAgendaDays(anchor, rangeEnd, instances, zone) return AgendaUiState.Success( anchor = anchor, today = todayDate, diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/widget/WidgetData.kt b/app/src/main/java/de/jeanlucmakiola/calendula/widget/WidgetData.kt index 0d4f8c3..df0f4bb 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/widget/WidgetData.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/widget/WidgetData.kt @@ -131,13 +131,14 @@ internal suspend fun Context.loadAgendaWidgetData(): AgendaWidgetData { // the composition from Glance state, so changing the range is a plain // recomposition and never depends on the widget session restarting. val window = agendaRange(anchor, AgendaRange.MAX_CUSTOM_DAYS - 1, zone) + val windowEnd = anchor.plus(AgendaRange.MAX_CUSTOM_DAYS - 1, DateTimeUnit.DAY) val instances = ep.calendarRepository().instances(window).first() val is24Hour = prefs.timeFormat.first() .is24Hour(android.text.format.DateFormat.is24HourFormat(this)) val soften = prefs.softenCalendarColors.first() return AgendaWidgetData.Ready( today = anchor, - days = groupAgendaDays(anchor, instances, zone), + days = groupAgendaDays(anchor, windowEnd, instances, zone), is24Hour = is24Hour, soften = soften, weekStart = weekStart, diff --git a/app/src/test/java/de/jeanlucmakiola/calendula/ui/agenda/GroupAgendaDaysTest.kt b/app/src/test/java/de/jeanlucmakiola/calendula/ui/agenda/GroupAgendaDaysTest.kt new file mode 100644 index 0000000..2c1eae9 --- /dev/null +++ b/app/src/test/java/de/jeanlucmakiola/calendula/ui/agenda/GroupAgendaDaysTest.kt @@ -0,0 +1,119 @@ +package de.jeanlucmakiola.calendula.ui.agenda + +import com.google.common.truth.Truth.assertThat +import de.jeanlucmakiola.calendula.domain.EventInstance +import kotlinx.datetime.LocalDate +import kotlinx.datetime.LocalDateTime +import kotlinx.datetime.TimeZone +import kotlinx.datetime.toInstant +import kotlin.time.Instant +import org.junit.jupiter.api.Test + +class GroupAgendaDaysTest { + + private val zone = TimeZone.UTC + private val anchor = LocalDate(2026, 7, 1) + // A wide window so clamping to the window end is out of the way unless tested. + private val windowEnd = LocalDate(2026, 7, 31) + + private fun at(y: Int, mo: Int, d: Int, h: Int = 0, min: Int = 0): Instant = + LocalDateTime(y, mo, d, h, min).toInstant(zone) + + private fun event( + id: Long, + title: String, + start: Instant, + end: Instant, + isAllDay: Boolean = false, + ) = EventInstance( + instanceId = id, + eventId = id, + calendarId = 1, + title = title, + start = start, + end = end, + isAllDay = isAllDay, + color = 0, + location = null, + ) + + private fun days(instances: List) = + groupAgendaDays(anchor, windowEnd, instances, zone) + + @Test + fun `a timed multi-day event lists on every day it spans`() { + val e = event(1, "trip", at(2026, 7, 1, 14, 0), at(2026, 7, 3, 10, 0)) + + val result = days(listOf(e)) + + assertThat(result.map { it.date }).containsExactly( + LocalDate(2026, 7, 1), + LocalDate(2026, 7, 2), + LocalDate(2026, 7, 3), + ).inOrder() + result.forEach { assertThat(it.events).containsExactly(e) } + } + + @Test + fun `an all-day multi-day event stops before its exclusive next-midnight end`() { + // 1–3 July inclusive: end is the exclusive midnight opening 4 July. + val e = event(1, "holiday", at(2026, 7, 1), at(2026, 7, 4), isAllDay = true) + + val result = days(listOf(e)) + + assertThat(result.map { it.date }).containsExactly( + LocalDate(2026, 7, 1), + LocalDate(2026, 7, 2), + LocalDate(2026, 7, 3), + ).inOrder() + } + + @Test + fun `a single-day event lists exactly once`() { + val e = event(1, "lunch", at(2026, 7, 2, 12, 0), at(2026, 7, 2, 13, 0)) + + assertThat(days(listOf(e)).map { it.date }) + .containsExactly(LocalDate(2026, 7, 2)) + } + + @Test + fun `an event ending exactly at midnight does not reach the next day`() { + val e = event(1, "late", at(2026, 7, 1, 22, 0), at(2026, 7, 2, 0, 0)) + + assertThat(days(listOf(e)).map { it.date }) + .containsExactly(LocalDate(2026, 7, 1)) + } + + @Test + fun `an event begun before the anchor is clamped to the anchor day`() { + val e = event(1, "ongoing", at(2026, 6, 29, 9, 0), at(2026, 7, 2, 9, 0)) + + assertThat(days(listOf(e)).map { it.date }).containsExactly( + LocalDate(2026, 7, 1), + LocalDate(2026, 7, 2), + ).inOrder() + } + + @Test + fun `an event running past the window is clamped to the last visible day`() { + val narrowEnd = LocalDate(2026, 7, 2) + val e = event(1, "long", at(2026, 7, 1, 9, 0), at(2026, 7, 5, 9, 0)) + + val result = groupAgendaDays(anchor, narrowEnd, listOf(e), zone) + + assertThat(result.map { it.date }).containsExactly( + LocalDate(2026, 7, 1), + LocalDate(2026, 7, 2), + ).inOrder() + } + + @Test + fun `within a day all-day events sort before timed ones`() { + val allDay = event(1, "birthday", at(2026, 7, 1), at(2026, 7, 2), isAllDay = true) + val timed = event(2, "call", at(2026, 7, 1, 9, 0), at(2026, 7, 1, 10, 0)) + + val dayOne = days(listOf(timed, allDay)).first { it.date == anchor } + + assertThat(dayOne.events).containsExactly(allDay, timed).inOrder() + } +} From c3ba8ccf649d689d5616eb7365f4ecedc039cffc Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Sun, 19 Jul 2026 11:19:53 +0200 Subject: [PATCH 13/78] test: fix CalendarRepositorySmokeTest against the current constructor MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CalendarRepositoryImpl gained a SettingsPrefs parameter, but this instrumented smoke test still called the three-arg constructor — so the whole androidTest source set failed to compile. Pass a SettingsPrefs built on the same DataStore. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../data/calendar/CalendarRepositorySmokeTest.kt | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/app/src/androidTest/java/de/jeanlucmakiola/calendula/data/calendar/CalendarRepositorySmokeTest.kt b/app/src/androidTest/java/de/jeanlucmakiola/calendula/data/calendar/CalendarRepositorySmokeTest.kt index fd76ebc..d6eea3f 100644 --- a/app/src/androidTest/java/de/jeanlucmakiola/calendula/data/calendar/CalendarRepositorySmokeTest.kt +++ b/app/src/androidTest/java/de/jeanlucmakiola/calendula/data/calendar/CalendarRepositorySmokeTest.kt @@ -9,6 +9,7 @@ import androidx.test.platform.app.InstrumentationRegistry import androidx.test.rule.GrantPermissionRule import com.google.common.truth.Truth.assertThat import de.jeanlucmakiola.calendula.data.prefs.CalendarPrefs +import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.first import kotlinx.coroutines.runBlocking @@ -31,7 +32,12 @@ class CalendarRepositorySmokeTest { val store: DataStore = PreferenceDataStoreFactory.create( produceFile = { context.cacheDir.resolve("smoke_test_prefs.preferences_pb") }, ) - return CalendarRepositoryImpl(dataSource, CalendarPrefs(store), Dispatchers.IO) + return CalendarRepositoryImpl( + dataSource, + CalendarPrefs(store), + SettingsPrefs(store), + Dispatchers.IO, + ) } @Test From 4ea68adf8b60b5702d6556b66b50a23ebae148ed Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Sun, 19 Jul 2026 11:25:22 +0200 Subject: [PATCH 14/78] feat(timezone): let the picker search by abbreviation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Search matched city, id, and the long localized name but not the abbreviation, so typing "CEST" found nothing. Match it too: an exact abbreviation hit ranks just under a city prefix, so typing an abbreviation gathers every zone that shows it (all the CEST zones at once). It matches the region-resolved abbreviation — i.e. exactly what the row displays. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../calendula/domain/TimeZoneCatalog.kt | 16 +++++++++++----- .../calendula/domain/TimeZoneCatalogTest.kt | 13 +++++++++++++ 2 files changed, 24 insertions(+), 5 deletions(-) diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/domain/TimeZoneCatalog.kt b/app/src/main/java/de/jeanlucmakiola/calendula/domain/TimeZoneCatalog.kt index e688a66..c107099 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/domain/TimeZoneCatalog.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/domain/TimeZoneCatalog.kt @@ -144,7 +144,10 @@ fun zoneDescriptor(option: TimeZoneOption): String { * Ranking puts a city that *starts with* the query above one that merely * contains it — typing "col" should reach Colombo before Turks_and_Caicos — * and the id is matched ahead of the localized name so a user who knows the - * IANA id gets it first. + * IANA id gets it first. An exact abbreviation hit ("CEST") ranks just under a + * city prefix, so typing an abbreviation surfaces every zone that shows it + * (all the CEST zones together); the abbreviation is the one resolved for the + * display region, i.e. what the row actually shows. */ fun filterTimeZones(options: List, query: String): List { val needle = query.normalizeForSearch() @@ -154,12 +157,15 @@ fun filterTimeZones(options: List, query: String): List 0 - name.startsWith(needle) -> 1 - city.contains(needle) -> 2 - id.contains(needle) -> 3 - name.contains(needle) -> 4 + abbrev == needle -> 1 + name.startsWith(needle) -> 2 + abbrev.startsWith(needle) -> 3 + city.contains(needle) -> 4 + id.contains(needle) -> 5 + name.contains(needle) -> 6 else -> return@mapNotNull null } rank to option diff --git a/app/src/test/java/de/jeanlucmakiola/calendula/domain/TimeZoneCatalogTest.kt b/app/src/test/java/de/jeanlucmakiola/calendula/domain/TimeZoneCatalogTest.kt index 1ef2512..56f6e8b 100644 --- a/app/src/test/java/de/jeanlucmakiola/calendula/domain/TimeZoneCatalogTest.kt +++ b/app/src/test/java/de/jeanlucmakiola/calendula/domain/TimeZoneCatalogTest.kt @@ -105,6 +105,19 @@ class TimeZoneCatalogTest { assertThat(filter("europe/berlin").map { it.id }).contains("Europe/Berlin") } + @Test + fun `query matches the abbreviation, case-insensitively`() { + // "cest" isn't a city, id, or substring of "Central European Summer + // Time", so the only way these match is via the abbreviation. + val hits = filter("cest") + assertThat(hits).isNotEmpty() + assertThat(hits.map { it.id }).contains("Europe/Berlin") + // Every hit genuinely carries that abbreviation — nothing bled in. + assertThat(hits.all { it.shortName.equals("CEST", ignoreCase = true) }).isTrue() + // Case doesn't matter. + assertThat(filter("CEST").map { it.id }).isEqualTo(hits.map { it.id }) + } + @Test fun `a city starting with the query outranks one merely containing it`() { val ids = filter("york").map { it.id } From 19baed9291e9f2060e5ed60793eba7fa2e39d2cd Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Sun, 19 Jul 2026 11:30:00 +0200 Subject: [PATCH 15/78] fix(agenda): scope LazyColumn key by day for spanning events A multi-day event now appears under every day it spans, so keying its row by instanceId alone repeated the key across days and crashed the LazyColumn ("Key already used") on scroll. Scope the key by day. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../de/jeanlucmakiola/calendula/ui/agenda/AgendaScreen.kt | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/agenda/AgendaScreen.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/agenda/AgendaScreen.kt index ebea45d..45e5f03 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/agenda/AgendaScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/agenda/AgendaScreen.kt @@ -367,7 +367,10 @@ private fun AgendaList( } else { itemsIndexed( items = day.events, - key = { _, event -> event.instanceId }, + // Scope the key by day: a multi-day event appears under every + // day it spans, so its instanceId alone is not unique across + // the list (LazyColumn requires unique keys). + key = { _, event -> "${day.date}-${event.instanceId}" }, ) { index, event -> AgendaEventRow( event = event, From 3feafe38f213b0e5db8d5da62853847557143fe8 Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Sun, 19 Jul 2026 11:39:01 +0200 Subject: [PATCH 16/78] feat(agenda): day-aware time line for multi-day events MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A multi-day event repeated the same "start – end" on every day it spans ("14:00 – 14:00"), which reads as meaningless. Show only the part relevant to each day, with a "→" marking that it carries past the day's boundary: the first day shows the start ("14:00 →"), the last day the end ("→ 10:00"), and whole days in between an all-day span arriving from and continuing into their neighbours ("→ All day →"). Single-day rows are unchanged. Factors the span first/last-day resolution into shared EventInstance helpers reused by groupAgendaDays and the label. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../calendula/ui/agenda/AgendaScreen.kt | 41 +++++++++++++++---- .../calendula/ui/agenda/AgendaUiState.kt | 26 +++++++++--- 2 files changed, 54 insertions(+), 13 deletions(-) diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/agenda/AgendaScreen.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/agenda/AgendaScreen.kt index 45e5f03..b512f9d 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/agenda/AgendaScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/agenda/AgendaScreen.kt @@ -374,6 +374,7 @@ private fun AgendaList( ) { index, event -> AgendaEventRow( event = event, + day = day.date, position = positionOf(index, day.events.size), dimmed = dimPast && event.hasEnded(now), modifier = animateItemMotion(), @@ -452,6 +453,7 @@ private fun AgendaEmptyDayRow(onClick: () -> Unit) { @Composable private fun AgendaEventRow( event: EventInstance, + day: LocalDate, position: Position, dimmed: Boolean, modifier: Modifier = Modifier, @@ -463,7 +465,7 @@ private fun AgendaEventRow( GroupedRow( modifier = if (dimmed) modifier.alpha(EventDimAlpha) else modifier, title = title, - summary = agendaTimeSummary(event), + summary = agendaTimeSummary(event, day), position = position, minHeight = 64.dp, leading = { @@ -557,16 +559,41 @@ private fun agendaDayLabel(date: LocalDate, today: LocalDate): String { return if (relative != null) "$relative · $formatted" else formatted } -/** Time line under the title: "09:00 – 10:00 · Location", "All day", etc. */ +/** + * Time line under the title: "09:00 – 10:00 · Location", "All day", etc. + * + * A multi-day event shows only the part relevant to [day], with a "→" marking + * that it carries past this day's boundary: its first day shows the start + * ("14:00 →"), its last day the end ("→ 10:00"), and any whole day in between + * reads as an all-day span arriving from and continuing into its neighbours + * ("→ All day →"). + */ @Composable -private fun agendaTimeSummary(event: EventInstance): String { - val time = if (event.isAllDay) { - stringResource(R.string.event_detail_all_day) +private fun agendaTimeSummary(event: EventInstance, day: LocalDate): String { + val allDayLabel = stringResource(R.string.event_detail_all_day) + val is24Hour = LocalUse24HourFormat.current + val locale = currentLocale() + + val time = if (event.spansMultipleDays(zone)) { + val continuesBefore = day > event.spanFirstDay(zone) + val continuesAfter = day < event.spanLastDay(zone) + val core = when { + event.isAllDay -> allDayLabel + !continuesBefore -> formatTime(event.start, is24Hour, locale) // the first day + !continuesAfter -> formatTime(event.end, is24Hour, locale) // the last day + else -> allDayLabel // a full middle day + } + buildString { + if (continuesBefore) append("→ ") + append(core) + if (continuesAfter) append(" →") + } + } else if (event.isAllDay) { + allDayLabel } else { - val is24Hour = LocalUse24HourFormat.current - val locale = currentLocale() "${formatTime(event.start, is24Hour, locale)} – ${formatTime(event.end, is24Hour, locale)}" } + val location = event.location?.takeIf { it.isNotBlank() } return if (location != null) "$time · $location" else time } 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 6419101..33438b7 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 @@ -9,6 +9,24 @@ import kotlinx.datetime.plus import kotlinx.datetime.toLocalDateTime import kotlin.time.Duration.Companion.milliseconds +/** The first calendar day this event occupies, in [zone]. */ +fun EventInstance.spanFirstDay(zone: TimeZone): LocalDate = + start.toLocalDateTime(zone).date + +/** + * The last calendar day this event actually occupies, in [zone]. An event ending + * exactly at midnight (all-day events end at the exclusive next-midnight) does + * not reach into that boundary day, so resolve the instant just before [end]. + */ +fun EventInstance.spanLastDay(zone: TimeZone): LocalDate { + val lastInstant = if (end > start) end - 1.milliseconds else start + return lastInstant.toLocalDateTime(zone).date +} + +/** Whether this event occupies more than one calendar day in [zone]. */ +fun EventInstance.spansMultipleDays(zone: TimeZone): Boolean = + spanFirstDay(zone) != spanLastDay(zone) + /** One calendar day with at least one event, for the agenda list. */ data class AgendaDay( val date: LocalDate, @@ -35,12 +53,8 @@ fun groupAgendaDays( ): List { val byDay = sortedMapOf>() for (instance in instances) { - val firstDay = instance.start.toLocalDateTime(zone).date.coerceAtLeast(anchor) - // The last day the event actually occupies. An event ending exactly at - // midnight (all-day events end at the exclusive next-midnight) does not - // reach into that boundary day, so resolve the instant just before [end]. - val lastInstant = if (instance.end > instance.start) instance.end - 1.milliseconds else instance.start - val lastDay = lastInstant.toLocalDateTime(zone).date.coerceAtMost(windowEnd) + val firstDay = instance.spanFirstDay(zone).coerceAtLeast(anchor) + val lastDay = instance.spanLastDay(zone).coerceAtMost(windowEnd) var day = firstDay while (day <= lastDay) { byDay.getOrPut(day) { mutableListOf() }.add(instance) From b5c2930609150f5341abfecf5e369ca1346a601a Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Sun, 19 Jul 2026 11:42:15 +0200 Subject: [PATCH 17/78] feat(agenda): spell out multi-day time line instead of arrows MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The "→" arrow convention read as unclear. Spell each day out instead: the first day names the start ("Starts 14:00"), the last day the end ("Ends 10:00"), and whole days in between read as "All day". All-day multi-day events stay "All day" on every day. Single-day rows unchanged. Adds agenda_span_starts / agenda_span_ends (owes Weblate backfill). Co-Authored-By: Claude Opus 4.8 (1M context) --- .../calendula/ui/agenda/AgendaScreen.kt | 26 +++++++------------ app/src/main/res/values/strings.xml | 3 +++ 2 files changed, 13 insertions(+), 16 deletions(-) diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/agenda/AgendaScreen.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/agenda/AgendaScreen.kt index b512f9d..697daf5 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/agenda/AgendaScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/agenda/AgendaScreen.kt @@ -562,11 +562,10 @@ private fun agendaDayLabel(date: LocalDate, today: LocalDate): String { /** * Time line under the title: "09:00 – 10:00 · Location", "All day", etc. * - * A multi-day event shows only the part relevant to [day], with a "→" marking - * that it carries past this day's boundary: its first day shows the start - * ("14:00 →"), its last day the end ("→ 10:00"), and any whole day in between - * reads as an all-day span arriving from and continuing into its neighbours - * ("→ All day →"). + * A multi-day event shows only the part relevant to [day], spelled out so each + * day reads on its own: its first day names the start ("Starts 14:00"), its last + * day the end ("Ends 10:00"), and any whole day in between reads as "All day". + * An all-day multi-day event is simply "All day" on every day it covers. */ @Composable private fun agendaTimeSummary(event: EventInstance, day: LocalDate): String { @@ -575,18 +574,13 @@ private fun agendaTimeSummary(event: EventInstance, day: LocalDate): String { val locale = currentLocale() val time = if (event.spansMultipleDays(zone)) { - val continuesBefore = day > event.spanFirstDay(zone) - val continuesAfter = day < event.spanLastDay(zone) - val core = when { + val isFirstDay = day <= event.spanFirstDay(zone) + val isLastDay = day >= event.spanLastDay(zone) + when { event.isAllDay -> allDayLabel - !continuesBefore -> formatTime(event.start, is24Hour, locale) // the first day - !continuesAfter -> formatTime(event.end, is24Hour, locale) // the last day - else -> allDayLabel // a full middle day - } - buildString { - if (continuesBefore) append("→ ") - append(core) - if (continuesAfter) append(" →") + isFirstDay -> stringResource(R.string.agenda_span_starts, formatTime(event.start, is24Hour, locale)) + isLastDay -> stringResource(R.string.agenda_span_ends, formatTime(event.end, is24Hour, locale)) + else -> allDayLabel // a full middle day } } else if (event.isAllDay) { allDayLabel diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 06f995d..5c22fb3 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -257,6 +257,9 @@ Tomorrow You\'re all caught up No more events today + + Starts %1$s + Ends %1$s Search From 7b7b859fee5b6da403fb9ff8ee5aede415a65030 Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Sun, 19 Jul 2026 11:47:13 +0200 Subject: [PATCH 18/78] docs(changelog): note per-event time zones (#31) Co-Authored-By: Claude Opus 4.8 (1M context) --- CHANGELOG.md | 14 ++++++++++++++ .../metadata/android/en-US/changelogs/21600.txt | 10 ++++++++++ 2 files changed, 24 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index b615505..34b5dc1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,19 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [2.16.0] — 2026-07-17 +### Added +- Give an event its own time zone. A new **Time zone** field (under "more + fields" in the event form) pins an event to a specific zone, so a call set for + 8:00 AM in New York stays 8:00 AM in New York wherever you open it — and keeps + tracking that zone across daylight-saving changes instead of drifting an hour. + The form edits the event in its own zone and shows the local equivalent under + the times ("2:00 PM – 3:00 PM your time"); the event's details keep your local + time first and note the original beneath it, so both are always clear. Pick a + zone from a full-screen picker with your device zone and recent choices on top, + searching by city ("new york"), IANA id ("europe/berlin"), or abbreviation + ("CEST") to gather every matching zone at once. All-day events stay + date-anchored and carry no zone, as before ([#31]). + ### Changed - Dates in the Month, Week and Day title bars now follow your language and region instead of one hardcoded layout. Every date was rendered in a fixed @@ -1006,6 +1019,7 @@ automatically, with zero telemetry and no internet permission. [#29]: https://codeberg.org/jlmakiola/calendula/issues/29 [#21]: https://codeberg.org/jlmakiola/calendula/issues/21 [#30]: https://codeberg.org/jlmakiola/calendula/issues/30 +[#31]: https://codeberg.org/jlmakiola/calendula/issues/31 [#32]: https://codeberg.org/jlmakiola/calendula/issues/32 [#33]: https://codeberg.org/jlmakiola/calendula/issues/33 [#34]: https://codeberg.org/jlmakiola/calendula/issues/34 diff --git a/fastlane/metadata/android/en-US/changelogs/21600.txt b/fastlane/metadata/android/en-US/changelogs/21600.txt index 4916a79..711f0e3 100644 --- a/fastlane/metadata/android/en-US/changelogs/21600.txt +++ b/fastlane/metadata/android/en-US/changelogs/21600.txt @@ -1,3 +1,13 @@ +### Added +- Give an event its own time zone. A new Time zone field (under "more fields" in + the event form) pins an event to a specific zone, so a call set for 8:00 AM in + New York stays 8:00 AM in New York wherever you open it, and keeps tracking + that zone across daylight-saving changes. The form shows the local equivalent + under the times, and the event's details keep your local time first with the + original noted beneath. Pick a zone from a searchable full-screen picker — by + city, IANA id, or abbreviation like "CEST". All-day events stay date-anchored + and carry no zone ([#31]). + ### Changed - Dates in the Month, Week and Day title bars now follow your language and region instead of one hardcoded layout. Every date was rendered in a fixed From 3ec46c86315a9f99e880ce8b5d2a3e694fcddac2 Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Sun, 19 Jul 2026 11:53:21 +0200 Subject: [PATCH 19/78] fix(agenda): resolve all-day span days in UTC, not the device zone MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review follow-up. spanFirstDay/spanLastDay resolved every event in the device zone, but all-day events live at UTC midnights with an exclusive end — east of UTC (e.g. Europe/Berlin) that pushed spanLastDay onto the next day, so a single-day all-day event reported spansMultipleDays and leaked onto a second agenda day. Resolve all-day dates in UTC, matching the Week view and detail card. Adds eastern-zone regression tests that the prior UTC-only tests could not catch. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../calendula/ui/agenda/AgendaUiState.kt | 21 +++++++++---- .../ui/agenda/GroupAgendaDaysTest.kt | 31 +++++++++++++++++++ 2 files changed, 46 insertions(+), 6 deletions(-) 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 33438b7..2e93d30 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 @@ -9,18 +9,27 @@ import kotlinx.datetime.plus import kotlinx.datetime.toLocalDateTime import kotlin.time.Duration.Companion.milliseconds -/** The first calendar day this event occupies, in [zone]. */ +/** + * The zone the event's calendar dates live in. Timed events are resolved in the + * device [zone]; all-day events live at UTC midnights with an exclusive end, so + * resolving them anywhere but UTC shifts the boundaries — east of UTC that leaks + * a one-day event onto its next day. Matches the Week view and detail card. + */ +private fun EventInstance.dateZone(zone: TimeZone): TimeZone = + if (isAllDay) TimeZone.UTC else zone + +/** The first calendar day this event occupies. */ fun EventInstance.spanFirstDay(zone: TimeZone): LocalDate = - start.toLocalDateTime(zone).date + start.toLocalDateTime(dateZone(zone)).date /** - * The last calendar day this event actually occupies, in [zone]. An event ending - * exactly at midnight (all-day events end at the exclusive next-midnight) does - * not reach into that boundary day, so resolve the instant just before [end]. + * The last calendar day this event actually occupies. An event ending exactly at + * midnight (all-day events end at the exclusive next-midnight) does not reach + * into that boundary day, so resolve the instant just before [end]. */ fun EventInstance.spanLastDay(zone: TimeZone): LocalDate { val lastInstant = if (end > start) end - 1.milliseconds else start - return lastInstant.toLocalDateTime(zone).date + return lastInstant.toLocalDateTime(dateZone(zone)).date } /** Whether this event occupies more than one calendar day in [zone]. */ diff --git a/app/src/test/java/de/jeanlucmakiola/calendula/ui/agenda/GroupAgendaDaysTest.kt b/app/src/test/java/de/jeanlucmakiola/calendula/ui/agenda/GroupAgendaDaysTest.kt index 2c1eae9..7cfd97f 100644 --- a/app/src/test/java/de/jeanlucmakiola/calendula/ui/agenda/GroupAgendaDaysTest.kt +++ b/app/src/test/java/de/jeanlucmakiola/calendula/ui/agenda/GroupAgendaDaysTest.kt @@ -107,6 +107,37 @@ class GroupAgendaDaysTest { ).inOrder() } + // All-day events are stored at UTC midnights; resolving them in a device + // zone east of UTC would leak them onto the following day (see spanLastDay). + private val berlin = TimeZone.of("Europe/Berlin") + + private fun utcMidnight(y: Int, mo: Int, d: Int): Instant = + LocalDateTime(y, mo, d, 0, 0).toInstant(TimeZone.UTC) + + @Test + fun `a single-day all-day event does not leak onto the next day in an eastern zone`() { + val e = event(1, "birthday", utcMidnight(2026, 7, 2), utcMidnight(2026, 7, 3), isAllDay = true) + + val result = groupAgendaDays(anchor, windowEnd, listOf(e), berlin) + + assertThat(result.map { it.date }).containsExactly(LocalDate(2026, 7, 2)) + assertThat(e.spansMultipleDays(berlin)).isFalse() + } + + @Test + fun `an all-day multi-day event spans its true days in an eastern zone`() { + // 2–4 July inclusive: exclusive end is the UTC midnight opening 5 July. + val e = event(1, "holiday", utcMidnight(2026, 7, 2), utcMidnight(2026, 7, 5), isAllDay = true) + + val result = groupAgendaDays(anchor, windowEnd, listOf(e), berlin) + + assertThat(result.map { it.date }).containsExactly( + LocalDate(2026, 7, 2), + LocalDate(2026, 7, 3), + LocalDate(2026, 7, 4), + ).inOrder() + } + @Test fun `within a day all-day events sort before timed ones`() { val allDay = event(1, "birthday", at(2026, 7, 1), at(2026, 7, 2), isAllDay = true) From 7de9b2f81b946b354be81558bca4bb4b6e1ce31d Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Sun, 19 Jul 2026 12:06:40 +0200 Subject: [PATCH 20/78] fix(agenda): share the day-aware time label with the widget MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Code-review follow-up. - The multi-day expansion in groupAgendaDays is shared with the agenda widget, but only the screen's summary was made day-aware — so the widget rendered the raw "start – end" on every spanned day, the very bug the screen fix cured. Hoist a pure agendaTimeLabel(event, day, zone) into the shared agenda layer and resolve strings from it in both the screen and the widget, so they label identically. (findings 1, 2) - groupAgendaDays could silently drop an instance whose clamped span was empty (firstDay > lastDay); floor lastDay at firstDay so a returned instance always surfaces on at least its first visible day. (finding 3) - agendaTimeLabel resolves the span days once instead of the summary recomputing them 2–3× per row. (finding 5) Finding 4 (within-day sort) needs no change: sorting by absolute start already places a still-running multi-day event at the top of each day it continues into, which is chronologically correct (it is ongoing from that day's midnight), and at its real start time on its first day. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../calendula/ui/agenda/AgendaScreen.kt | 22 ++---- .../calendula/ui/agenda/AgendaUiState.kt | 36 ++++++++- .../calendula/widget/agenda/AgendaWidget.kt | 29 +++++-- .../ui/agenda/AgendaTimeLabelTest.kt | 76 +++++++++++++++++++ 4 files changed, 140 insertions(+), 23 deletions(-) create mode 100644 app/src/test/java/de/jeanlucmakiola/calendula/ui/agenda/AgendaTimeLabelTest.kt diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/agenda/AgendaScreen.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/agenda/AgendaScreen.kt index 697daf5..0f5b5c2 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/agenda/AgendaScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/agenda/AgendaScreen.kt @@ -569,23 +569,17 @@ private fun agendaDayLabel(date: LocalDate, today: LocalDate): String { */ @Composable private fun agendaTimeSummary(event: EventInstance, day: LocalDate): String { - val allDayLabel = stringResource(R.string.event_detail_all_day) val is24Hour = LocalUse24HourFormat.current val locale = currentLocale() - val time = if (event.spansMultipleDays(zone)) { - val isFirstDay = day <= event.spanFirstDay(zone) - val isLastDay = day >= event.spanLastDay(zone) - when { - event.isAllDay -> allDayLabel - isFirstDay -> stringResource(R.string.agenda_span_starts, formatTime(event.start, is24Hour, locale)) - isLastDay -> stringResource(R.string.agenda_span_ends, formatTime(event.end, is24Hour, locale)) - else -> allDayLabel // a full middle day - } - } else if (event.isAllDay) { - allDayLabel - } else { - "${formatTime(event.start, is24Hour, locale)} – ${formatTime(event.end, is24Hour, locale)}" + val time = when (val label = agendaTimeLabel(event, day, zone)) { + AgendaTimeLabel.AllDay -> stringResource(R.string.event_detail_all_day) + is AgendaTimeLabel.Starts -> + stringResource(R.string.agenda_span_starts, formatTime(label.start, is24Hour, locale)) + is AgendaTimeLabel.Ends -> + stringResource(R.string.agenda_span_ends, formatTime(label.end, is24Hour, locale)) + is AgendaTimeLabel.Range -> + "${formatTime(label.start, is24Hour, locale)} – ${formatTime(label.end, is24Hour, locale)}" } val location = event.location?.takeIf { it.isNotBlank() } 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 2e93d30..7f20d44 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 @@ -8,6 +8,7 @@ import kotlinx.datetime.TimeZone import kotlinx.datetime.plus import kotlinx.datetime.toLocalDateTime import kotlin.time.Duration.Companion.milliseconds +import kotlin.time.Instant /** * The zone the event's calendar dates live in. Timed events are resolved in the @@ -36,6 +37,36 @@ fun EventInstance.spanLastDay(zone: TimeZone): LocalDate { fun EventInstance.spansMultipleDays(zone: TimeZone): Boolean = spanFirstDay(zone) != spanLastDay(zone) +/** + * What an agenda row's time line should convey for an event on a given day — + * the part of a multi-day span that [day] falls in. Pure and shared so the + * agenda screen and the agenda widget label multi-day events identically; each + * surface only formats the instants into its own locale/24h string. + */ +sealed interface AgendaTimeLabel { + /** An all-day event, or a whole in-between day of a multi-day span. */ + data object AllDay : AgendaTimeLabel + /** The first day of a multi-day timed event: when it begins. */ + data class Starts(val start: Instant) : AgendaTimeLabel + /** The last day of a multi-day timed event: when it ends. */ + data class Ends(val end: Instant) : AgendaTimeLabel + /** A single-day timed event: its start–end range. */ + data class Range(val start: Instant, val end: Instant) : AgendaTimeLabel +} + +/** The [AgendaTimeLabel] for [event] as it appears on [day], resolved in [zone]. */ +fun agendaTimeLabel(event: EventInstance, day: LocalDate, zone: TimeZone): AgendaTimeLabel { + if (event.isAllDay) return AgendaTimeLabel.AllDay + val firstDay = event.spanFirstDay(zone) + val lastDay = event.spanLastDay(zone) + return when { + firstDay == lastDay -> AgendaTimeLabel.Range(event.start, event.end) + day <= firstDay -> AgendaTimeLabel.Starts(event.start) + day >= lastDay -> AgendaTimeLabel.Ends(event.end) + else -> AgendaTimeLabel.AllDay // a full in-between day + } +} + /** One calendar day with at least one event, for the agenda list. */ data class AgendaDay( val date: LocalDate, @@ -63,7 +94,10 @@ fun groupAgendaDays( val byDay = sortedMapOf>() for (instance in instances) { val firstDay = instance.spanFirstDay(zone).coerceAtLeast(anchor) - val lastDay = instance.spanLastDay(zone).coerceAtMost(windowEnd) + // Never below firstDay: an instance the query returned always surfaces on + // at least its first visible day, even if its end resolves earlier (e.g. a + // zero-length or boundary instant) — otherwise the loop would drop it. + val lastDay = instance.spanLastDay(zone).coerceAtMost(windowEnd).coerceAtLeast(firstDay) var day = firstDay while (day <= lastDay) { byDay.getOrPut(day) { mutableListOf() }.add(instance) diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/widget/agenda/AgendaWidget.kt b/app/src/main/java/de/jeanlucmakiola/calendula/widget/agenda/AgendaWidget.kt index 2b048fc..198a21a 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/widget/agenda/AgendaWidget.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/widget/agenda/AgendaWidget.kt @@ -49,6 +49,8 @@ import de.jeanlucmakiola.calendula.data.prefs.parsePastEventDisplay import de.jeanlucmakiola.calendula.domain.EventInstance import de.jeanlucmakiola.calendula.domain.hasEnded import de.jeanlucmakiola.calendula.ui.agenda.AgendaRange +import de.jeanlucmakiola.calendula.ui.agenda.AgendaTimeLabel +import de.jeanlucmakiola.calendula.ui.agenda.agendaTimeLabel import de.jeanlucmakiola.calendula.ui.agenda.anchorTodayIfMissing import de.jeanlucmakiola.calendula.ui.agenda.dayCount import de.jeanlucmakiola.calendula.ui.agenda.parseAgendaRange @@ -127,7 +129,7 @@ class RefreshAgendaAction : ActionCallback { /** 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 + data class Event(val date: LocalDate, val event: EventInstance) : AgendaRow /** "Nothing left today" line under an anchored, event-less today (#35). */ data class Placeholder(val date: LocalDate) : AgendaRow } @@ -185,7 +187,7 @@ private fun AgendaWidgetBody(data: AgendaWidgetData, dark: Boolean) { if (day.events.isEmpty()) { add(AgendaRow.Placeholder(day.date)) } else { - day.events.forEach { add(AgendaRow.Event(it)) } + day.events.forEach { add(AgendaRow.Event(day.date, it)) } } } } @@ -196,6 +198,7 @@ private fun AgendaWidgetBody(data: AgendaWidgetData, dark: Boolean) { is AgendaRow.Placeholder -> PlaceholderRow(row.date) is AgendaRow.Event -> EventRow( event = row.event, + day = row.date, dark = dark, soften = data.soften, is24Hour = data.is24Hour, @@ -312,6 +315,7 @@ private fun PlaceholderRow(date: LocalDate) { @Composable private fun EventRow( event: EventInstance, + day: LocalDate, dark: Boolean, soften: Boolean, is24Hour: Boolean, @@ -357,7 +361,7 @@ private fun EventRow( style = TextStyle(color = titleColor, fontSize = 14.sp), ) Text( - text = eventTimeSummary(context, event, is24Hour), + text = eventTimeSummary(context, event, day, is24Hour), maxLines = 1, style = TextStyle(color = GlanceTheme.colors.onSurfaceVariant, fontSize = 12.sp), ) @@ -396,11 +400,20 @@ private fun agendaDayLabel(context: Context, date: LocalDate, today: LocalDate): return if (relative != null) "$relative · $formatted" else formatted } -private fun eventTimeSummary(context: Context, event: EventInstance, is24Hour: Boolean): String { - val time = if (event.isAllDay) { - context.getString(R.string.event_detail_all_day) - } else { - "${formatTime(event.start, is24Hour)} – ${formatTime(event.end, is24Hour)}" +private fun eventTimeSummary( + context: Context, + event: EventInstance, + day: LocalDate, + is24Hour: Boolean, +): String { + val time = when (val label = agendaTimeLabel(event, day, zone())) { + AgendaTimeLabel.AllDay -> context.getString(R.string.event_detail_all_day) + is AgendaTimeLabel.Starts -> + context.getString(R.string.agenda_span_starts, formatTime(label.start, is24Hour)) + is AgendaTimeLabel.Ends -> + context.getString(R.string.agenda_span_ends, formatTime(label.end, is24Hour)) + is AgendaTimeLabel.Range -> + "${formatTime(label.start, is24Hour)} – ${formatTime(label.end, is24Hour)}" } val location = event.location?.takeIf { it.isNotBlank() } return if (location != null) "$time · $location" else time diff --git a/app/src/test/java/de/jeanlucmakiola/calendula/ui/agenda/AgendaTimeLabelTest.kt b/app/src/test/java/de/jeanlucmakiola/calendula/ui/agenda/AgendaTimeLabelTest.kt new file mode 100644 index 0000000..25c71ed --- /dev/null +++ b/app/src/test/java/de/jeanlucmakiola/calendula/ui/agenda/AgendaTimeLabelTest.kt @@ -0,0 +1,76 @@ +package de.jeanlucmakiola.calendula.ui.agenda + +import com.google.common.truth.Truth.assertThat +import de.jeanlucmakiola.calendula.domain.EventInstance +import kotlinx.datetime.LocalDate +import kotlinx.datetime.LocalDateTime +import kotlinx.datetime.TimeZone +import kotlinx.datetime.toInstant +import kotlin.time.Instant +import org.junit.jupiter.api.Test + +class AgendaTimeLabelTest { + + private val zone = TimeZone.UTC + + private fun at(y: Int, mo: Int, d: Int, h: Int = 0, min: Int = 0): Instant = + LocalDateTime(y, mo, d, h, min).toInstant(zone) + + private fun event(start: Instant, end: Instant, isAllDay: Boolean = false) = EventInstance( + instanceId = 1, + eventId = 1, + calendarId = 1, + title = "e", + start = start, + end = end, + isAllDay = isAllDay, + color = 0, + location = null, + ) + + private fun labelOn(y: Int, mo: Int, d: Int, event: EventInstance) = + agendaTimeLabel(event, LocalDate(y, mo, d), zone) + + @Test + fun `a single-day timed event is a start-end range`() { + val e = event(at(2026, 7, 2, 12, 0), at(2026, 7, 2, 13, 0)) + + assertThat(labelOn(2026, 7, 2, e)) + .isEqualTo(AgendaTimeLabel.Range(e.start, e.end)) + } + + @Test + fun `a single-day all-day event is all-day`() { + val e = event(at(2026, 7, 2), at(2026, 7, 3), isAllDay = true) + + assertThat(labelOn(2026, 7, 2, e)).isEqualTo(AgendaTimeLabel.AllDay) + } + + @Test + fun `a multi-day timed event names the start, middle, and end days`() { + val e = event(at(2026, 7, 1, 14, 0), at(2026, 7, 4, 10, 0)) + + assertThat(labelOn(2026, 7, 1, e)).isEqualTo(AgendaTimeLabel.Starts(e.start)) + assertThat(labelOn(2026, 7, 2, e)).isEqualTo(AgendaTimeLabel.AllDay) + assertThat(labelOn(2026, 7, 3, e)).isEqualTo(AgendaTimeLabel.AllDay) + assertThat(labelOn(2026, 7, 4, e)).isEqualTo(AgendaTimeLabel.Ends(e.end)) + } + + @Test + fun `a multi-day all-day event is all-day on every day`() { + val e = event(at(2026, 7, 2), at(2026, 7, 5), isAllDay = true) + + assertThat(labelOn(2026, 7, 2, e)).isEqualTo(AgendaTimeLabel.AllDay) + assertThat(labelOn(2026, 7, 3, e)).isEqualTo(AgendaTimeLabel.AllDay) + assertThat(labelOn(2026, 7, 4, e)).isEqualTo(AgendaTimeLabel.AllDay) + } + + @Test + fun `an event begun before the shown day is not labelled as starting`() { + // Runs 29 Jun 09:00 → 2 Jul 09:00; on 1 Jul it is mid-span, on 2 Jul it ends. + val e = event(at(2026, 6, 29, 9, 0), at(2026, 7, 2, 9, 0)) + + assertThat(labelOn(2026, 7, 1, e)).isEqualTo(AgendaTimeLabel.AllDay) + assertThat(labelOn(2026, 7, 2, e)).isEqualTo(AgendaTimeLabel.Ends(e.end)) + } +} From 46afa830b3e1da5391878f06d93a2762c80c19dc Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Sun, 19 Jul 2026 12:12:54 +0200 Subject: [PATCH 21/78] feat(views): optional jump-to-today button in the toolbar (#60) Add a "Today button in toolbar" appearance toggle (default off). When on, each calendar view (month/week/day/agenda) shows a persistent go-to-today icon button in its top bar instead of the fade-in extended FAB, and the FAB pill is suppressed. Same jump action, just always present. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../calendula/data/prefs/SettingsPrefs.kt | 16 ++++++++++++++++ .../jeanlucmakiola/calendula/ui/CalendarHost.kt | 6 ++++++ .../calendula/ui/CalendarHostViewModel.kt | 8 ++++++++ .../calendula/ui/agenda/AgendaScreen.kt | 16 +++++++++++++++- .../jeanlucmakiola/calendula/ui/day/DayScreen.kt | 16 +++++++++++++++- .../calendula/ui/month/MonthScreen.kt | 16 +++++++++++++++- .../calendula/ui/settings/SettingsScreen.kt | 12 ++++++++++++ .../calendula/ui/settings/SettingsUiState.kt | 2 ++ .../calendula/ui/settings/SettingsViewModel.kt | 13 +++++++++++-- .../calendula/ui/week/WeekScreen.kt | 16 +++++++++++++++- app/src/main/res/values/strings.xml | 5 +++++ 11 files changed, 120 insertions(+), 6 deletions(-) diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/data/prefs/SettingsPrefs.kt b/app/src/main/java/de/jeanlucmakiola/calendula/data/prefs/SettingsPrefs.kt index 2b0d8ea..1026f2e 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/data/prefs/SettingsPrefs.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/data/prefs/SettingsPrefs.kt @@ -234,6 +234,21 @@ class SettingsPrefs @Inject constructor( store.edit { it[SHOW_WEEK_NUMBERS_KEY] = enabled } } + /** + * Where the jump-to-today control lives (issue #60). Default OFF — the + * historical layout, where it's an extended FAB that fades in above the "+" + * only while the view is off today. Turning it ON moves it to a persistent + * icon button in each calendar view's top bar (always shown, on today or + * not) and drops the FAB pill. + */ + val todayButtonInToolbar: Flow = store.data.map { prefs -> + prefs[TODAY_BUTTON_IN_TOOLBAR_KEY] ?: false + } + + suspend fun setTodayButtonInToolbar(enabled: Boolean) { + store.edit { it[TODAY_BUTTON_IN_TOOLBAR_KEY] = enabled } + } + /** * How far ahead the in-app Agenda screen shows events (v2.11). Defaults to * [AgendaRange.Month] — a month of upcoming events. Independent of the @@ -738,6 +753,7 @@ class SettingsPrefs @Inject constructor( internal val PAST_EVENT_DISPLAY_KEY = stringPreferencesKey("agenda_past_event_display") internal val DIM_COMPLETED_EVENTS_KEY = booleanPreferencesKey("dim_completed_events") internal val SHOW_WEEK_NUMBERS_KEY = booleanPreferencesKey("show_week_numbers") + internal val TODAY_BUTTON_IN_TOOLBAR_KEY = booleanPreferencesKey("today_button_in_toolbar") internal val DEFAULT_VIEW_KEY = stringPreferencesKey("default_view") internal val QUICK_SWITCH_VIEWS_KEY = stringPreferencesKey("quick_switch_views") internal val DRAWER_VIEW_ORDER_KEY = stringPreferencesKey("drawer_view_order") 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 c18fd34..cf5abea 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/CalendarHost.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/CalendarHost.kt @@ -88,6 +88,8 @@ fun CalendarHost( // sensible non-empty initial values, so they're ready before the first frame. val quickSwitchViews = viewModel.quickSwitchViews.collectAsStateWithLifecycle().value val drawerViewOrder = viewModel.drawerViewOrder.collectAsStateWithLifecycle().value + // Whether the jump-to-today control sits in each view's top bar or the FAB (#60). + val todayInToolbar = viewModel.todayButtonInToolbar.collectAsStateWithLifecycle().value var viewStack by rememberSaveable(stateSaver = viewStackSaver) { mutableStateOf(listOf(defaultView)) @@ -318,6 +320,7 @@ fun CalendarHost( onCreateEvent = onCreateEvent, quickSwitchViews = quickSwitchViews, drawerViewOrder = drawerViewOrder, + todayInToolbar = todayInToolbar, ) CalendarView.Day -> DayScreen( selectedView = currentView, @@ -329,6 +332,7 @@ fun CalendarHost( initialDateIso = pendingDayIso, quickSwitchViews = quickSwitchViews, drawerViewOrder = drawerViewOrder, + todayInToolbar = todayInToolbar, ) CalendarView.Month -> MonthScreen( selectedView = currentView, @@ -339,6 +343,7 @@ fun CalendarHost( onCreateEvent = onCreateEvent, quickSwitchViews = quickSwitchViews, drawerViewOrder = drawerViewOrder, + todayInToolbar = todayInToolbar, ) CalendarView.Agenda -> AgendaScreen( selectedView = currentView, @@ -350,6 +355,7 @@ fun CalendarHost( onCreateEvent = onCreateEvent, quickSwitchViews = quickSwitchViews, drawerViewOrder = drawerViewOrder, + todayInToolbar = todayInToolbar, ) } } diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/CalendarHostViewModel.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/CalendarHostViewModel.kt index 773f76d..a5f3104 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/CalendarHostViewModel.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/CalendarHostViewModel.kt @@ -45,4 +45,12 @@ class CalendarHostViewModel @Inject constructor( started = SharingStarted.WhileSubscribed(5_000L), initialValue = IMPLEMENTED_VIEWS, ) + + /** Whether each view's jump-to-today control lives in the top bar, not the FAB (#60). */ + val todayButtonInToolbar: StateFlow = prefs.todayButtonInToolbar + .stateIn( + scope = viewModelScope, + started = SharingStarted.WhileSubscribed(5_000L), + initialValue = false, + ) } diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/agenda/AgendaScreen.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/agenda/AgendaScreen.kt index ebea45d..39817c7 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/agenda/AgendaScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/agenda/AgendaScreen.kt @@ -22,6 +22,7 @@ import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Coffee import androidx.compose.material.icons.filled.Menu import androidx.compose.material.icons.filled.Search +import androidx.compose.material.icons.filled.Today import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.Card import androidx.compose.material3.DrawerValue @@ -102,6 +103,7 @@ fun AgendaScreen( onCreateEvent: (LocalDate, Int?) -> Unit, quickSwitchViews: List = IMPLEMENTED_VIEWS, drawerViewOrder: List = IMPLEMENTED_VIEWS, + todayInToolbar: Boolean = false, modifier: Modifier = Modifier, viewModel: AgendaViewModel = hiltViewModel(), ) { @@ -151,12 +153,14 @@ fun AgendaScreen( onCycleView = { onSelectView(selectedView.next(quickSwitchViews)) }, onOpenDrawer = { scope.launch { drawerState.open() } }, onOpenSearch = onOpenSearch, + showTodayButton = todayInToolbar, + onToday = viewModel::goToToday, scrollBehavior = scrollBehavior, ) }, floatingActionButton = { CalendarFabColumn( - todayVisible = !isOnToday, + todayVisible = !isOnToday && !todayInToolbar, todayText = stringResource(R.string.agenda_today_action), onToday = viewModel::goToToday, onCreate = { onCreateEvent(anchor, null) }, @@ -504,6 +508,8 @@ private fun AgendaTopBar( onCycleView: () -> Unit, onOpenDrawer: () -> Unit, onOpenSearch: () -> Unit, + showTodayButton: Boolean, + onToday: () -> Unit, scrollBehavior: TopAppBarScrollBehavior, ) { TopAppBar( @@ -522,6 +528,14 @@ private fun AgendaTopBar( } }, actions = { + if (showTodayButton) { + IconButton(onClick = onToday) { + Icon( + imageVector = Icons.Default.Today, + contentDescription = stringResource(R.string.today_jump_action), + ) + } + } IconButton(onClick = onOpenSearch) { Icon( imageVector = Icons.Default.Search, diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/day/DayScreen.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/day/DayScreen.kt index 8507102..8e750e1 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/day/DayScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/day/DayScreen.kt @@ -27,6 +27,7 @@ import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Menu import androidx.compose.material.icons.filled.Search +import androidx.compose.material.icons.filled.Today import androidx.compose.material3.Card import androidx.compose.material3.CardDefaults import androidx.compose.material3.DrawerValue @@ -128,6 +129,7 @@ fun DayScreen( onCreateEvent: (LocalDate, Int?) -> Unit, quickSwitchViews: List = IMPLEMENTED_VIEWS, drawerViewOrder: List = IMPLEMENTED_VIEWS, + todayInToolbar: Boolean = false, modifier: Modifier = Modifier, initialDateIso: String? = null, viewModel: DayViewModel = hiltViewModel(), @@ -220,12 +222,14 @@ fun DayScreen( onCycleView = { onSelectView(selectedView.next(quickSwitchViews)) }, onOpenDrawer = { scope.launch { drawerState.open() } }, onOpenSearch = onOpenSearch, + showTodayButton = todayInToolbar, + onToday = jumpToToday, scrollBehavior = scrollBehavior, ) }, floatingActionButton = { CalendarFabColumn( - todayVisible = !isOnToday, + todayVisible = !isOnToday && !todayInToolbar, todayText = stringResource(R.string.day_today_action), onToday = jumpToToday, onCreate = { onCreateEvent(date, null) }, @@ -376,6 +380,8 @@ private fun DayTopBar( onCycleView: () -> Unit, onOpenDrawer: () -> Unit, onOpenSearch: () -> Unit, + showTodayButton: Boolean, + onToday: () -> Unit, scrollBehavior: androidx.compose.material3.TopAppBarScrollBehavior, ) { val locale = currentLocale() @@ -395,6 +401,14 @@ private fun DayTopBar( } }, actions = { + if (showTodayButton) { + IconButton(onClick = onToday) { + Icon( + imageVector = Icons.Default.Today, + contentDescription = stringResource(R.string.today_jump_action), + ) + } + } IconButton(onClick = onOpenSearch) { Icon( imageVector = Icons.Default.Search, diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthScreen.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthScreen.kt index fbf5e26..6aa60f5 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthScreen.kt @@ -24,6 +24,7 @@ import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Menu import androidx.compose.material.icons.filled.Search +import androidx.compose.material.icons.filled.Today import androidx.compose.material3.DrawerValue import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon @@ -106,6 +107,7 @@ fun MonthScreen( onCreateEvent: (LocalDate, Int?) -> Unit, quickSwitchViews: List = IMPLEMENTED_VIEWS, drawerViewOrder: List = IMPLEMENTED_VIEWS, + todayInToolbar: Boolean = false, modifier: Modifier = Modifier, viewModel: MonthViewModel = hiltViewModel(), ) { @@ -199,12 +201,14 @@ fun MonthScreen( onCycleView = { onSelectView(selectedView.next(quickSwitchViews)) }, onOpenDrawer = { scope.launch { drawerState.open() } }, onOpenSearch = onOpenSearch, + showTodayButton = todayInToolbar, + onToday = jumpToToday, scrollBehavior = scrollBehavior, ) }, floatingActionButton = { CalendarFabColumn( - todayVisible = !isOnCurrentMonth, + todayVisible = !isOnCurrentMonth && !todayInToolbar, todayText = stringResource(R.string.month_today_action), onToday = jumpToToday, onCreate = { @@ -308,6 +312,8 @@ private fun MonthTopBar( onCycleView: () -> Unit, onOpenDrawer: () -> Unit, onOpenSearch: () -> Unit, + showTodayButton: Boolean, + onToday: () -> Unit, scrollBehavior: androidx.compose.material3.TopAppBarScrollBehavior, ) { val locale = currentLocale() @@ -327,6 +333,14 @@ private fun MonthTopBar( } }, actions = { + if (showTodayButton) { + IconButton(onClick = onToday) { + Icon( + imageVector = Icons.Default.Today, + contentDescription = stringResource(R.string.today_jump_action), + ) + } + } IconButton(onClick = onOpenSearch) { Icon( imageVector = Icons.Default.Search, diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/SettingsScreen.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/SettingsScreen.kt index dd5c76e..7339a75 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/SettingsScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/SettingsScreen.kt @@ -571,6 +571,18 @@ private fun AppearanceScreen( position = Position.Top, onClick = { showDefaultView = true }, ) + GroupedRow( + title = stringResource(R.string.settings_today_toolbar), + summary = stringResource(R.string.settings_today_toolbar_summary), + position = Position.Middle, + trailing = { + Switch( + checked = state.todayButtonInToolbar, + onCheckedChange = viewModel::setTodayButtonInToolbar, + ) + }, + onClick = { viewModel.setTodayButtonInToolbar(!state.todayButtonInToolbar) }, + ) GroupedRow( title = stringResource(R.string.settings_week_start), summary = weekStartLabel(state.weekStart), diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/SettingsUiState.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/SettingsUiState.kt index 1bbc6c2..4fbb436 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/SettingsUiState.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/SettingsUiState.kt @@ -37,6 +37,8 @@ data class SettingsUiState( val dimCompletedEvents: Boolean = false, /** Whether the Month grid shows calendar-week numbers in a left gutter (#25). */ val showWeekNumbers: Boolean = false, + /** Whether the jump-to-today control sits in the top bar instead of the FAB (#60). */ + val todayButtonInToolbar: Boolean = false, /** How far ahead the in-app Agenda screen shows events (v2.11). */ val agendaScreenRange: AgendaRange = AgendaRange.Month, /** How far ahead the agenda widget shows events (v2.11). */ diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/SettingsViewModel.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/SettingsViewModel.kt index 22d1a6b..96727c9 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/SettingsViewModel.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/SettingsViewModel.kt @@ -124,8 +124,9 @@ class SettingsViewModel @Inject constructor( prefs.showWeekNumbers, prefs.agendaShowToday, prefs.softenCalendarColors, - ) { hourLines, weekNumbers, showToday, soften -> - DisplayToggles(hourLines, weekNumbers, showToday, soften) + prefs.todayButtonInToolbar, + ) { hourLines, weekNumbers, showToday, soften, todayInToolbar -> + DisplayToggles(hourLines, weekNumbers, showToday, soften, todayInToolbar) }, ) { view, screenRange, widgetRange, timeFormat, toggles -> ViewSettings( @@ -134,6 +135,7 @@ class SettingsViewModel @Inject constructor( showWeekNumbers = toggles.showWeekNumbers, agendaShowToday = toggles.agendaShowToday, softenColors = toggles.softenColors, + todayButtonInToolbar = toggles.todayButtonInToolbar, ) }, combine( @@ -159,6 +161,7 @@ class SettingsViewModel @Inject constructor( showWeekNumbers = views.showWeekNumbers, agendaShowToday = views.agendaShowToday, softenColors = views.softenColors, + todayButtonInToolbar = views.todayButtonInToolbar, agendaShowRangeBar = misc.showRangeBar, autofocusEventTitle = misc.autofocusEventTitle, pastEventDisplay = misc.pastEventDisplay, @@ -234,6 +237,7 @@ class SettingsViewModel @Inject constructor( val showWeekNumbers: Boolean, val agendaShowToday: Boolean, val softenColors: Boolean, + val todayButtonInToolbar: Boolean, ) private data class DisplayToggles( @@ -241,6 +245,7 @@ class SettingsViewModel @Inject constructor( val showWeekNumbers: Boolean, val agendaShowToday: Boolean, val softenColors: Boolean, + val todayButtonInToolbar: Boolean, ) private data class MiscSettings( @@ -460,6 +465,10 @@ class SettingsViewModel @Inject constructor( viewModelScope.launch { prefs.setShowWeekNumbers(enabled) } } + fun setTodayButtonInToolbar(enabled: Boolean) { + viewModelScope.launch { prefs.setTodayButtonInToolbar(enabled) } + } + fun setPastEventDisplay(mode: PastEventDisplay) { viewModelScope.launch { prefs.setPastEventDisplay(mode) diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/week/WeekScreen.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/week/WeekScreen.kt index 8cba3e2..141481a 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/week/WeekScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/week/WeekScreen.kt @@ -30,6 +30,7 @@ import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Menu import androidx.compose.material.icons.filled.Search +import androidx.compose.material.icons.filled.Today import androidx.compose.material3.Card import androidx.compose.material3.CardDefaults import androidx.compose.material3.DrawerValue @@ -144,6 +145,7 @@ fun WeekScreen( onCreateEvent: (LocalDate, Int?) -> Unit, quickSwitchViews: List = IMPLEMENTED_VIEWS, drawerViewOrder: List = IMPLEMENTED_VIEWS, + todayInToolbar: Boolean = false, modifier: Modifier = Modifier, viewModel: WeekViewModel = hiltViewModel(), ) { @@ -241,12 +243,14 @@ fun WeekScreen( onCycleView = { onSelectView(selectedView.next(quickSwitchViews)) }, onOpenDrawer = { scope.launch { drawerState.open() } }, onOpenSearch = onOpenSearch, + showTodayButton = todayInToolbar, + onToday = jumpToToday, scrollBehavior = scrollBehavior, ) }, floatingActionButton = { CalendarFabColumn( - todayVisible = !isOnCurrentWeek, + todayVisible = !isOnCurrentWeek && !todayInToolbar, todayText = stringResource(R.string.week_today_action), onToday = jumpToToday, onCreate = { @@ -409,6 +413,8 @@ private fun WeekTopBar( onCycleView: () -> Unit, onOpenDrawer: () -> Unit, onOpenSearch: () -> Unit, + showTodayButton: Boolean, + onToday: () -> Unit, scrollBehavior: androidx.compose.material3.TopAppBarScrollBehavior, ) { val locale = currentLocale() @@ -428,6 +434,14 @@ private fun WeekTopBar( } }, actions = { + if (showTodayButton) { + IconButton(onClick = onToday) { + Icon( + imageVector = Icons.Default.Today, + contentDescription = stringResource(R.string.today_jump_action), + ) + } + } IconButton(onClick = onOpenSearch) { Icon( imageVector = Icons.Default.Search, diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index eb03dd9..2ebafe9 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -271,6 +271,9 @@ You\'re all caught up No more events today + + Go to today + Search Search events @@ -321,6 +324,8 @@ Automatic Week numbers Show calendar-week numbers in month view + Today button in toolbar + Show a jump-to-today button in the toolbar instead of a floating button Time format Automatic 12-hour (2:00 PM) From 85d72ad051895859590bf0e89f48f93388ebc807 Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Sun, 19 Jul 2026 12:18:47 +0200 Subject: [PATCH 22/78] refactor(views): extract shared TodayAction top-bar button Collapse the today icon-button block that was copy-pasted into all four calendar top bars (Week/Month/Day/Agenda) into one TodayAction composable in ui/common. No behaviour change. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../calendula/ui/agenda/AgendaScreen.kt | 11 ++------ .../calendula/ui/common/TodayAction.kt | 26 +++++++++++++++++++ .../calendula/ui/day/DayScreen.kt | 11 ++------ .../calendula/ui/month/MonthScreen.kt | 11 ++------ .../calendula/ui/week/WeekScreen.kt | 11 ++------ 5 files changed, 34 insertions(+), 36 deletions(-) create mode 100644 app/src/main/java/de/jeanlucmakiola/calendula/ui/common/TodayAction.kt diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/agenda/AgendaScreen.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/agenda/AgendaScreen.kt index 39817c7..1b648fb 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/agenda/AgendaScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/agenda/AgendaScreen.kt @@ -22,7 +22,6 @@ import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Coffee import androidx.compose.material.icons.filled.Menu import androidx.compose.material.icons.filled.Search -import androidx.compose.material.icons.filled.Today import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.Card import androidx.compose.material3.DrawerValue @@ -66,6 +65,7 @@ import de.jeanlucmakiola.calendula.ui.common.agendaRangeLabel import de.jeanlucmakiola.floret.identity.animateItemMotion import de.jeanlucmakiola.calendula.ui.common.CalendarDrawer import de.jeanlucmakiola.calendula.ui.common.CalendarFabColumn +import de.jeanlucmakiola.calendula.ui.common.TodayAction import de.jeanlucmakiola.calendula.ui.common.CalendarFailure import de.jeanlucmakiola.calendula.ui.common.CalendarView import de.jeanlucmakiola.calendula.ui.common.IMPLEMENTED_VIEWS @@ -528,14 +528,7 @@ private fun AgendaTopBar( } }, actions = { - if (showTodayButton) { - IconButton(onClick = onToday) { - Icon( - imageVector = Icons.Default.Today, - contentDescription = stringResource(R.string.today_jump_action), - ) - } - } + TodayAction(show = showTodayButton, onToday = onToday) IconButton(onClick = onOpenSearch) { Icon( imageVector = Icons.Default.Search, diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/TodayAction.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/TodayAction.kt new file mode 100644 index 0000000..1234bb1 --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/TodayAction.kt @@ -0,0 +1,26 @@ +package de.jeanlucmakiola.calendula.ui.common + +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Today +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.runtime.Composable +import androidx.compose.ui.res.stringResource +import de.jeanlucmakiola.calendula.R + +/** + * The top-bar "jump to today" icon button, shared by every calendar view's app + * bar (#60). Shown in place of the fade-in FAB pill when the user moves the + * today control into the toolbar; renders nothing when [show] is false, so it + * drops straight into an app bar's `actions` slot without a wrapping condition. + */ +@Composable +fun TodayAction(show: Boolean, onToday: () -> Unit) { + if (!show) return + IconButton(onClick = onToday) { + Icon( + imageVector = Icons.Default.Today, + contentDescription = stringResource(R.string.today_jump_action), + ) + } +} diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/day/DayScreen.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/day/DayScreen.kt index 8e750e1..4526677 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/day/DayScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/day/DayScreen.kt @@ -27,7 +27,6 @@ import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Menu import androidx.compose.material.icons.filled.Search -import androidx.compose.material.icons.filled.Today import androidx.compose.material3.Card import androidx.compose.material3.CardDefaults import androidx.compose.material3.DrawerValue @@ -72,6 +71,7 @@ import de.jeanlucmakiola.calendula.domain.EventInstance import de.jeanlucmakiola.calendula.ui.common.formatCalendarTitle import de.jeanlucmakiola.calendula.ui.common.CalendarDrawer import de.jeanlucmakiola.calendula.ui.common.CalendarFabColumn +import de.jeanlucmakiola.calendula.ui.common.TodayAction import de.jeanlucmakiola.calendula.ui.common.CalendarFailure import de.jeanlucmakiola.calendula.ui.common.CalendarView import de.jeanlucmakiola.calendula.ui.common.IMPLEMENTED_VIEWS @@ -401,14 +401,7 @@ private fun DayTopBar( } }, actions = { - if (showTodayButton) { - IconButton(onClick = onToday) { - Icon( - imageVector = Icons.Default.Today, - contentDescription = stringResource(R.string.today_jump_action), - ) - } - } + TodayAction(show = showTodayButton, onToday = onToday) IconButton(onClick = onOpenSearch) { Icon( imageVector = Icons.Default.Search, diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthScreen.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthScreen.kt index 6aa60f5..7f501dc 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthScreen.kt @@ -24,7 +24,6 @@ import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Menu import androidx.compose.material.icons.filled.Search -import androidx.compose.material.icons.filled.Today import androidx.compose.material3.DrawerValue import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon @@ -68,6 +67,7 @@ import de.jeanlucmakiola.calendula.domain.hasEnded import de.jeanlucmakiola.calendula.ui.common.formatCalendarTitle import de.jeanlucmakiola.calendula.ui.common.CalendarDrawer import de.jeanlucmakiola.calendula.ui.common.CalendarFabColumn +import de.jeanlucmakiola.calendula.ui.common.TodayAction import de.jeanlucmakiola.calendula.ui.common.CalendarFailure import de.jeanlucmakiola.calendula.ui.common.CalendarView import de.jeanlucmakiola.calendula.ui.common.IMPLEMENTED_VIEWS @@ -333,14 +333,7 @@ private fun MonthTopBar( } }, actions = { - if (showTodayButton) { - IconButton(onClick = onToday) { - Icon( - imageVector = Icons.Default.Today, - contentDescription = stringResource(R.string.today_jump_action), - ) - } - } + TodayAction(show = showTodayButton, onToday = onToday) IconButton(onClick = onOpenSearch) { Icon( imageVector = Icons.Default.Search, diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/week/WeekScreen.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/week/WeekScreen.kt index 141481a..bd44535 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/week/WeekScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/week/WeekScreen.kt @@ -30,7 +30,6 @@ import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Menu import androidx.compose.material.icons.filled.Search -import androidx.compose.material.icons.filled.Today import androidx.compose.material3.Card import androidx.compose.material3.CardDefaults import androidx.compose.material3.DrawerValue @@ -81,6 +80,7 @@ import de.jeanlucmakiola.calendula.domain.hasEnded import de.jeanlucmakiola.calendula.ui.common.formatCalendarTitle import de.jeanlucmakiola.calendula.ui.common.CalendarDrawer import de.jeanlucmakiola.calendula.ui.common.CalendarFabColumn +import de.jeanlucmakiola.calendula.ui.common.TodayAction import de.jeanlucmakiola.calendula.ui.common.CalendarFailure import de.jeanlucmakiola.calendula.ui.common.CalendarView import de.jeanlucmakiola.calendula.ui.common.IMPLEMENTED_VIEWS @@ -434,14 +434,7 @@ private fun WeekTopBar( } }, actions = { - if (showTodayButton) { - IconButton(onClick = onToday) { - Icon( - imageVector = Icons.Default.Today, - contentDescription = stringResource(R.string.today_jump_action), - ) - } - } + TodayAction(show = showTodayButton, onToday = onToday) IconButton(onClick = onOpenSearch) { Icon( imageVector = Icons.Default.Search, From 1b29abed17a83ad3f169a776e9c86ed77c326430 Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Sun, 19 Jul 2026 12:24:08 +0200 Subject: [PATCH 23/78] docs(changelog): note the toolbar today-button option (#60) Co-Authored-By: Claude Opus 4.8 (1M context) --- CHANGELOG.md | 6 ++++++ fastlane/metadata/android/en-US/changelogs/21600.txt | 4 ++++ 2 files changed, 10 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 34b5dc1..9e901b4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,6 +21,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 searching by city ("new york"), IANA id ("europe/berlin"), or abbreviation ("CEST") to gather every matching zone at once. All-day events stay date-anchored and carry no zone, as before ([#31]). +- Put the "jump to today" button in the toolbar. A new **Today button in + toolbar** setting (Settings → Appearance, off by default) swaps the floating + button that fades into the corner while you're away from today for a permanent + today icon in the top bar — always there, on today or not, matching the + familiar calendar-app pattern. Leave it off to keep the floating button as + before ([#60]). ### Changed - Dates in the Month, Week and Day title bars now follow your language and diff --git a/fastlane/metadata/android/en-US/changelogs/21600.txt b/fastlane/metadata/android/en-US/changelogs/21600.txt index 711f0e3..2cf7d30 100644 --- a/fastlane/metadata/android/en-US/changelogs/21600.txt +++ b/fastlane/metadata/android/en-US/changelogs/21600.txt @@ -7,6 +7,10 @@ original noted beneath. Pick a zone from a searchable full-screen picker — by city, IANA id, or abbreviation like "CEST". All-day events stay date-anchored and carry no zone ([#31]). +- Put the "jump to today" button in the toolbar. A new Today button in toolbar + setting (Settings → Appearance, off by default) swaps the floating corner + button for a permanent today icon in the top bar — always there, matching the + familiar calendar-app pattern. Leave it off to keep the floating button ([#60]). ### Changed - Dates in the Month, Week and Day title bars now follow your language and From 4148196a360c366a8396eb198758d7f1059f8e9e Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Sun, 19 Jul 2026 16:05:36 +0200 Subject: [PATCH 24/78] fix(agenda): stop yesterday's all-day event surfacing under today (#65) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The agenda window starts at the anchor's local midnight, which east of UTC is the previous day's 22:00 UTC. All-day events live at UTC midnights with an exclusive next-midnight end, so yesterday's all-day event (a birthday, say) still overlaps today's window start and is returned by the provider. groupAgendaDays then clamped its first day up to the anchor and, via a trailing coerceAtLeast(firstDay) on the last day, pulled it onto the anchor's "today" section — the multi-day fix only stopped the forward leak on interior days. Drop instances whose true last day (resolved in UTC for all-day events) falls before the anchor, or whose first day falls past the window end: they occupy no visible day and must not be clamped onto an edge. Adds eastern-zone regression tests for both edges. Co-Authored-By: Claude Opus 4.8 (1M context) --- CHANGELOG.md | 10 ++++++++ .../calendula/ui/agenda/AgendaUiState.kt | 14 ++++++++--- .../ui/agenda/GroupAgendaDaysTest.kt | 25 +++++++++++++++++++ 3 files changed, 45 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9e901b4..c3ce563 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -46,6 +46,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 headers right below it, in the widest string in the bar. A week that straddles two months keeps the outgoing month until it is fully gone ([#60]). +### Fixed +- An all-day event no longer shows up again the day after it happened. In time + zones east of UTC, an all-day event — a birthday, say — set for one day also + appeared under the *next* day's heading in the Agenda (and the agenda widget), + because all-day events are anchored to UTC midnight and the following day's + window reached back across that boundary and pulled the event forward onto + "today". Each all-day event now lists only on the day it actually falls on + ([#65]). + ## [2.15.0] — 2026-07-15 ### Added @@ -1040,3 +1049,4 @@ automatically, with zero telemetry and no internet permission. [#49]: https://codeberg.org/jlmakiola/calendula/issues/49 [#52]: https://codeberg.org/jlmakiola/calendula/issues/52 [#60]: https://codeberg.org/jlmakiola/calendula/issues/60 +[#65]: https://codeberg.org/jlmakiola/calendula/issues/65 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 7f20d44..b88b1a7 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 @@ -94,10 +94,16 @@ fun groupAgendaDays( val byDay = sortedMapOf>() for (instance in instances) { val firstDay = instance.spanFirstDay(zone).coerceAtLeast(anchor) - // Never below firstDay: an instance the query returned always surfaces on - // at least its first visible day, even if its end resolves earlier (e.g. a - // zero-length or boundary instant) — otherwise the loop would drop it. - val lastDay = instance.spanLastDay(zone).coerceAtMost(windowEnd).coerceAtLeast(firstDay) + val lastDay = instance.spanLastDay(zone).coerceAtMost(windowEnd) + // Skip instances that don't actually occupy any day in [[anchor], [windowEnd]]. + // The provider returns an event whenever its instant span overlaps the query + // window, but all-day events live at UTC midnights with an exclusive end: east + // of UTC that end dips just past local midnight, so *yesterday's* all-day event + // overlaps today's window start and comes back even though its true last day + // (resolved in UTC) is before the anchor. Clamping it up to the anchor would + // surface it under "today" (issue #65); drop it instead. The symmetric case — + // a next-day all-day event overlapping the window's last instant — drops too. + if (lastDay < firstDay) continue var day = firstDay while (day <= lastDay) { byDay.getOrPut(day) { mutableListOf() }.add(instance) diff --git a/app/src/test/java/de/jeanlucmakiola/calendula/ui/agenda/GroupAgendaDaysTest.kt b/app/src/test/java/de/jeanlucmakiola/calendula/ui/agenda/GroupAgendaDaysTest.kt index 7cfd97f..179d93f 100644 --- a/app/src/test/java/de/jeanlucmakiola/calendula/ui/agenda/GroupAgendaDaysTest.kt +++ b/app/src/test/java/de/jeanlucmakiola/calendula/ui/agenda/GroupAgendaDaysTest.kt @@ -138,6 +138,31 @@ class GroupAgendaDaysTest { ).inOrder() } + @Test + fun `yesterday's all-day event does not surface under the anchor day in an eastern zone`() { + // The agenda window starts at the anchor's local midnight, which in Berlin is + // 22:00 UTC the day before — so an all-day event that ended yesterday (whose + // exclusive UTC-midnight end sits at that instant) is still returned by the + // provider. It must not be clamped onto the anchor's "today" section (#65). + val jul19 = LocalDate(2026, 7, 19) + val e = event(1, "birthday", utcMidnight(2026, 7, 18), utcMidnight(2026, 7, 19), isAllDay = true) + + val result = groupAgendaDays(jul19, LocalDate(2026, 8, 18), listOf(e), berlin) + + assertThat(result).isEmpty() + } + + @Test + fun `an all-day event that falls entirely past the window end is dropped`() { + // The same guard, at the far edge: an instance whose true days are all beyond + // windowEnd occupies no visible day and must not be clamped onto the last one. + val e = event(1, "birthday", utcMidnight(2026, 7, 3), utcMidnight(2026, 7, 4), isAllDay = true) + + val result = groupAgendaDays(anchor, LocalDate(2026, 7, 2), listOf(e), berlin) + + assertThat(result).isEmpty() + } + @Test fun `within a day all-day events sort before timed ones`() { val allDay = event(1, "birthday", at(2026, 7, 1), at(2026, 7, 2), isAllDay = true) From 03f6b7c8c1f72b94532eafa51c7d2c94ca62ae16 Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Sun, 19 Jul 2026 16:39:08 +0200 Subject: [PATCH 25/78] feat(settings): optional "Calendar" launcher name (#44) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a Settings → Appearance toggle that switches the app's launcher label between "Calendula" and "Calendar", for users on launchers that can't rename apps themselves. The launcher entry moves off MainActivity onto two components (DefaultNameAlias / CalendarNameAlias); exactly one is enabled at a time via PackageManager.setComponentEnabledSetting. MainActivity keeps every other intent filter; the android.app.shortcuts meta-data moves onto both aliases so the long-press shortcut still publishes. Component-enabled state is the single source of truth — no persisted preference. The ComponentName uses the applicationId for the package (carrying the .debug/.releasetest suffix) and the namespace for the class, since manifest ".Alias" names resolve against the namespace; switching to Calendar enables the target alias before disabling the other to avoid a zero-entry launcher transient. Co-Authored-By: Claude Opus 4.8 (1M context) --- app/src/main/AndroidManifest.xml | 47 +++++++- .../data/appname/LauncherNameManager.kt | 109 ++++++++++++++++++ .../calendula/ui/settings/SettingsScreen.kt | 32 +++++ .../ui/settings/SettingsViewModel.kt | 22 ++++ app/src/main/res/values/strings.xml | 6 + .../data/appname/LauncherNameManagerTest.kt | 51 ++++++++ .../android/en-US/changelogs/21600.txt | 5 + 7 files changed, 266 insertions(+), 6 deletions(-) create mode 100644 app/src/main/java/de/jeanlucmakiola/calendula/data/appname/LauncherNameManager.kt create mode 100644 app/src/test/java/de/jeanlucmakiola/calendula/data/appname/LauncherNameManagerTest.kt diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 930ab70..7f73919 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -63,10 +63,9 @@ android:exported="true" android:launchMode="singleTop" android:windowSoftInputMode="adjustResize"> - - - - + + + + + + + + + - + + + + + + + + + Calendar Loading… @@ -329,6 +333,8 @@ Show calendar-week numbers in month view Today button in toolbar Show a jump-to-today button in the toolbar instead of a floating button + App name + Show Calendula as “Calendar” in your launcher. Only the launcher name changes; the icon may move to a new spot after switching. Time format Automatic 12-hour (2:00 PM) diff --git a/app/src/test/java/de/jeanlucmakiola/calendula/data/appname/LauncherNameManagerTest.kt b/app/src/test/java/de/jeanlucmakiola/calendula/data/appname/LauncherNameManagerTest.kt new file mode 100644 index 0000000..c806dce --- /dev/null +++ b/app/src/test/java/de/jeanlucmakiola/calendula/data/appname/LauncherNameManagerTest.kt @@ -0,0 +1,51 @@ +package de.jeanlucmakiola.calendula.data.appname + +import android.content.pm.PackageManager +import com.google.common.truth.Truth.assertThat +import org.junit.jupiter.api.Test + +/** + * Pure decision logic behind the app-name toggle (issue #44). The framework seam + * ([LauncherNameManager.set]/[current], which call `PackageManager`) is covered + * on-device; these tests pin the read interpretation and the write ordering. + */ +class LauncherNameManagerTest { + + @Test + fun `enabled calendar alias reads as CALENDAR`() { + assertThat(launcherNameFor(PackageManager.COMPONENT_ENABLED_STATE_ENABLED)) + .isEqualTo(LauncherName.CALENDAR) + } + + @Test + fun `default and disabled calendar alias read as CALENDULA`() { + assertThat(launcherNameFor(PackageManager.COMPONENT_ENABLED_STATE_DEFAULT)) + .isEqualTo(LauncherName.CALENDULA) + assertThat(launcherNameFor(PackageManager.COMPONENT_ENABLED_STATE_DISABLED)) + .isEqualTo(LauncherName.CALENDULA) + } + + @Test + fun `write plan enables the target alias before disabling the other`() { + val toCalendar = aliasWritePlan(LauncherName.CALENDAR) + assertThat(toCalendar).containsExactly( + AliasStateChange(LauncherAlias.CALENDAR, enabled = true), + AliasStateChange(LauncherAlias.DEFAULT, enabled = false), + ).inOrder() + + val toCalendula = aliasWritePlan(LauncherName.CALENDULA) + assertThat(toCalendula).containsExactly( + AliasStateChange(LauncherAlias.DEFAULT, enabled = true), + AliasStateChange(LauncherAlias.CALENDAR, enabled = false), + ).inOrder() + } + + @Test + fun `write plan never disables both aliases in the same step`() { + // The first step is always an enable, so the launcher can never observe a + // zero-entry transient regardless of switch direction. + for (target in LauncherName.entries) { + assertThat(aliasWritePlan(target).first().enabled).isTrue() + } + } +} diff --git a/fastlane/metadata/android/en-US/changelogs/21600.txt b/fastlane/metadata/android/en-US/changelogs/21600.txt index 2cf7d30..b96a9c1 100644 --- a/fastlane/metadata/android/en-US/changelogs/21600.txt +++ b/fastlane/metadata/android/en-US/changelogs/21600.txt @@ -11,6 +11,11 @@ setting (Settings → Appearance, off by default) swaps the floating corner button for a permanent today icon in the top bar — always there, matching the familiar calendar-app pattern. Leave it off to keep the floating button ([#60]). +- Show the app as "Calendar" in your launcher. A new App name setting + (Settings → Appearance) switches the launcher label from "Calendula" to the + generic "Calendar" for anyone who prefers it — handy on launchers that can't + rename apps themselves. Only the launcher name changes; your home-screen icon + may move to a new spot after switching ([#44]). ### Changed - Dates in the Month, Week and Day title bars now follow your language and From ccd433fee104c22e5cd126100089d24154faa09e Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Sun, 19 Jul 2026 16:56:55 +0200 Subject: [PATCH 26/78] refactor(edit): redesign the custom recurrence picker (#42) Rebuild the custom step of the recurrence picker so complex-but-supported rules (e.g. "every 2 weeks on Mon+Tue") are discoverable and legible, rather than buried in a ragged stack of mismatched controls. - Add a live, human-readable summary of the rule as it is built, via the existing recurrenceText humanizer. - Group interval + frequency into one tonal card; the frequency is now a SingleChoiceSegmentedButtonRow (all four units visible) instead of a dropdown. - Reveal the weekday picks with AnimatedVisibility and house them, the "every" controls, and the ends group in consistent 16dp-inset grouped cards; the count field folds into the bottom of the ends run. No behaviour, domain, or RRULE changes: parse/render logic and the set of expressible rules are unchanged. The EXDATE half of #42 is deferred. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../calendula/ui/edit/EventEditScreen.kt | 217 ++++++++++++------ 1 file changed, 144 insertions(+), 73 deletions(-) diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/edit/EventEditScreen.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/edit/EventEditScreen.kt index 3f5d920..5eeee1f 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/edit/EventEditScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/edit/EventEditScreen.kt @@ -1382,84 +1382,154 @@ private fun RecurrencePickerDialog( ) } else { Column( - verticalArrangement = Arrangement.spacedBy(8.dp), - modifier = Modifier.padding(horizontal = 24.dp), + modifier = Modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(16.dp), ) { - Row(verticalAlignment = Alignment.CenterVertically) { - Text( - text = stringResource(R.string.event_edit_recurrence_every), - style = MaterialTheme.typography.titleMedium, - ) - Spacer(Modifier.width(12.dp)) - DialogAmountField( - value = intervalText, - onValueChange = { intervalText = it }, - placeholder = "1", - ) - Spacer(Modifier.width(12.dp)) - DialogUnitDropdown( - label = stringResource(recurrenceUnitLabel(freq)), - entries = RecurrenceFreq.entries.map { - stringResource(recurrenceUnitLabel(it)) - }, - onPick = { freq = RecurrenceFreq.entries[it] }, - ) - } - if (freq == RecurrenceFreq.Weekly) { - WeekdayToggleRow( - selected = daysMask.toDaySet(), - onToggle = { day -> daysMask = daysMask xor day.toMaskBit() }, - locale = locale, - ) - } + // A live, human-readable read-out of the rule being built, so the + // effect of the controls below is never a guess. Text( - text = stringResource(R.string.event_edit_recurrence_ends), - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, + text = recurrenceText( + SimpleRecurrence( + freq = freq, + interval = interval ?: 1, + end = customEnd ?: RecurrenceEnd.Never, + byDays = if (freq == RecurrenceFreq.Weekly) { + daysMask.toDaySet() + } else { + emptySet() + }, + ).toRRule(), + locale, + ), + style = MaterialTheme.typography.titleMedium, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), ) - } - GroupedRow( - title = stringResource(R.string.event_edit_recurrence_end_never), - position = positionOf(0, 3), - selected = endMode == RecurrenceEndMode.Never, - onClick = { endMode = RecurrenceEndMode.Never }, - ) - GroupedRow( - title = stringResource(R.string.event_edit_recurrence_end_until), - summary = untilDate?.let { - remember(it, locale) { - DateTimeFormatter.ofLocalizedDate(FormatStyle.MEDIUM) - .withLocale(locale).format(it.toJavaLocalDate()) - } - }, - position = positionOf(1, 3), - selected = endMode == RecurrenceEndMode.Until, - onClick = { - endMode = RecurrenceEndMode.Until - showUntilPicker = true - }, - ) - GroupedRow( - title = stringResource(R.string.event_edit_recurrence_end_count), - position = positionOf(2, 3), - selected = endMode == RecurrenceEndMode.Count, - onClick = { endMode = RecurrenceEndMode.Count }, - ) - if (endMode == RecurrenceEndMode.Count) { - Row( - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier.padding(horizontal = 24.dp, vertical = 4.dp), + + // How often: an interval amount plus a frequency segmented row — + // all four units on show, no unit hidden behind a dropdown. + Surface( + color = MaterialTheme.colorScheme.surfaceContainerHigh, + shape = groupedShape(Position.Alone), + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), ) { - DialogAmountField( - value = countText, - onValueChange = { countText = it }, - placeholder = "10", - ) - Spacer(Modifier.width(12.dp)) + Column( + modifier = Modifier.padding(16.dp), + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + Row(verticalAlignment = Alignment.CenterVertically) { + Text( + text = stringResource(R.string.event_edit_recurrence_every), + style = MaterialTheme.typography.titleMedium, + ) + Spacer(Modifier.width(12.dp)) + DialogAmountField( + value = intervalText, + onValueChange = { intervalText = it }, + placeholder = "1", + ) + } + SingleChoiceSegmentedButtonRow(modifier = Modifier.fillMaxWidth()) { + RecurrenceFreq.entries.forEachIndexed { index, entry -> + SegmentedButton( + selected = freq == entry, + onClick = { freq = entry }, + shape = SegmentedButtonDefaults.itemShape( + index, RecurrenceFreq.entries.size, + ), + label = { Text(stringResource(recurrenceUnitLabel(entry))) }, + ) + } + } + } + } + + // Weekday picks — only meaningful on a weekly rule. + AnimatedVisibility(visible = freq == RecurrenceFreq.Weekly) { + Surface( + color = MaterialTheme.colorScheme.surfaceContainerHigh, + shape = groupedShape(Position.Alone), + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + ) { + WeekdayToggleRow( + selected = daysMask.toDaySet(), + onToggle = { day -> daysMask = daysMask xor day.toMaskBit() }, + locale = locale, + modifier = Modifier.padding(16.dp), + ) + } + } + + // Ends: a connected never / on-a-date / after-N group, the count + // field folding into the bottom of the run when chosen. + Column { Text( - text = stringResource(R.string.event_edit_recurrence_times), - style = MaterialTheme.typography.titleMedium, + text = stringResource(R.string.event_edit_recurrence_ends), + style = MaterialTheme.typography.labelLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(start = 16.dp, bottom = 8.dp), ) + GroupedRow( + title = stringResource(R.string.event_edit_recurrence_end_never), + position = Position.Top, + selected = endMode == RecurrenceEndMode.Never, + onClick = { endMode = RecurrenceEndMode.Never }, + ) + GroupedRow( + title = stringResource(R.string.event_edit_recurrence_end_until), + summary = untilDate?.let { + remember(it, locale) { + DateTimeFormatter.ofLocalizedDate(FormatStyle.MEDIUM) + .withLocale(locale).format(it.toJavaLocalDate()) + } + }, + position = Position.Middle, + selected = endMode == RecurrenceEndMode.Until, + onClick = { + endMode = RecurrenceEndMode.Until + showUntilPicker = true + }, + ) + GroupedRow( + title = stringResource(R.string.event_edit_recurrence_end_count), + position = if (endMode == RecurrenceEndMode.Count) { + Position.Middle + } else { + Position.Bottom + }, + selected = endMode == RecurrenceEndMode.Count, + onClick = { endMode = RecurrenceEndMode.Count }, + ) + if (endMode == RecurrenceEndMode.Count) { + Surface( + color = MaterialTheme.colorScheme.surfaceContainerHigh, + shape = groupedShape(Position.Bottom), + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.padding(16.dp), + ) { + DialogAmountField( + value = countText, + onValueChange = { countText = it }, + placeholder = "10", + ) + Spacer(Modifier.width(12.dp)) + Text( + text = stringResource(R.string.event_edit_recurrence_times), + style = MaterialTheme.typography.titleMedium, + ) + } + } + } } } } @@ -1489,6 +1559,7 @@ private fun WeekdayToggleRow( selected: Set, onToggle: (DayOfWeek) -> Unit, locale: Locale, + modifier: Modifier = Modifier, ) { val days = remember(locale) { val first = WeekFields.of(locale).firstDayOfWeek.toKotlinDayOfWeek() @@ -1496,7 +1567,7 @@ private fun WeekdayToggleRow( } Row( horizontalArrangement = Arrangement.SpaceBetween, - modifier = Modifier.fillMaxWidth(), + modifier = modifier.fillMaxWidth(), ) { days.forEach { day -> val isSelected = day in selected From 91c4a84818f5f1d344d1183c709f99e824a131ae Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Sun, 19 Jul 2026 16:57:28 +0200 Subject: [PATCH 27/78] refactor(settings): app-name as a full-screen chooser, not a switch Replace the inline App name switch with a GroupedRow that opens a full-screen OptionPicker (Calendula / Calendar), matching the app's other "choose one" settings and the ReFra pattern the user preferred. The row shows the current name; the picker's header carries the explanatory + icon-may-move hint. Leaves room for more launcher names later without redesigning the row. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../calendula/ui/settings/SettingsScreen.kt | 50 +++++++++++-------- 1 file changed, 28 insertions(+), 22 deletions(-) diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/SettingsScreen.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/SettingsScreen.kt index e5f26fb..84babc8 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/SettingsScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/SettingsScreen.kt @@ -488,6 +488,7 @@ private fun AppearanceScreen( var showPastEvents by remember { mutableStateOf(false) } var showBrandFont by remember { mutableStateOf(false) } var showPlainFont by remember { mutableStateOf(false) } + var showAppName by remember { mutableStateOf(false) } val fonts by viewModel.fontState.collectAsStateWithLifecycle() val launcherName by viewModel.launcherName.collectAsStateWithLifecycle() @@ -684,35 +685,31 @@ private fun AppearanceScreen( Spacer(Modifier.height(16.dp)) - // App name — flips the launcher label between "Calendula" and "Calendar" + // App name — chooses the launcher label between "Calendula" and "Calendar" // (issue #44). Own group: it's a launcher/system concern, not calendar - // formatting. + // formatting. A sub-page chooser (not a switch), matching the app's other + // "choose one" settings and leaving room for more names later. GroupedRow( title = stringResource(R.string.settings_app_name), - summary = stringResource(R.string.settings_app_name_summary), + summary = launcherNameLabel(launcherName), position = Position.Alone, - trailing = { - Switch( - checked = launcherName == LauncherName.CALENDAR, - onCheckedChange = { - viewModel.setLauncherName( - if (it) LauncherName.CALENDAR else LauncherName.CALENDULA, - ) - }, - ) - }, - onClick = { - viewModel.setLauncherName( - if (launcherName == LauncherName.CALENDAR) { - LauncherName.CALENDULA - } else { - LauncherName.CALENDAR - }, - ) - }, + onClick = { showAppName = true }, ) } + if (showAppName) { + OptionPicker( + title = stringResource(R.string.settings_app_name), + predictiveBack = true, + options = LauncherName.entries, + selected = launcherName, + label = { launcherNameLabel(it) }, + onSelect = viewModel::setLauncherName, + onDismiss = { showAppName = false }, + // Explain what changes (and the icon-may-move caveat) above the choices. + header = { SettingsHint(stringResource(R.string.settings_app_name_summary)) }, + ) + } if (showTheme) { OptionPicker( title = stringResource(R.string.settings_theme), @@ -1736,6 +1733,15 @@ private fun openUrl(context: Context, url: String) { runCatching { context.startActivity(intent) } } +/** The display name for a launcher-label choice (issue #44). */ +@Composable +private fun launcherNameLabel(name: LauncherName): String = stringResource( + when (name) { + LauncherName.CALENDULA -> R.string.app_name + LauncherName.CALENDAR -> R.string.app_name_calendar_alias + }, +) + @Composable private fun themeLabel(mode: ThemeMode): String = stringResource( when (mode) { From 3ffe76412eee2e37fcc4e31571656bdccb4c9d2d Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Sun, 19 Jul 2026 17:05:14 +0200 Subject: [PATCH 28/78] feat(settings): launcher-mark hero on the App name picker MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a preview hero to the App name picker header: the app's launcher mark over the current name on a tonal surface card, the explanatory hint below it, then generous space before the choices. Gives the picker the visual "display" the inline hint lacked and separates the text from the options. Follows the material-3 guidance — tonal surfaceContainerHigh card (no shadow), 28dp corner, 8dp-grid spacing, onSurface/onSurfaceVariant role pairing — and reuses the onboarding BrandHero's squircle reconstruction of the adaptive icon so it renders identically everywhere. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../calendula/ui/settings/SettingsScreen.kt | 64 ++++++++++++++++++- 1 file changed, 62 insertions(+), 2 deletions(-) diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/SettingsScreen.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/SettingsScreen.kt index 84babc8..d7d2e12 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/SettingsScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/SettingsScreen.kt @@ -706,8 +706,10 @@ private fun AppearanceScreen( label = { launcherNameLabel(it) }, onSelect = viewModel::setLauncherName, onDismiss = { showAppName = false }, - // Explain what changes (and the icon-may-move caveat) above the choices. - header = { SettingsHint(stringResource(R.string.settings_app_name_summary)) }, + // A launcher-mark hero shows how the name reads under the icon, with + // the explanatory + icon-may-move hint, then breathing room above the + // choices. + header = { AppNamePreviewHero(launcherName) }, ) } if (showTheme) { @@ -1742,6 +1744,64 @@ private fun launcherNameLabel(name: LauncherName): String = stringResource( }, ) +/** + * Header for the App name picker (issue #44): the app's launcher mark over the + * current name — a small preview of how the home screen reads — with the + * explanatory hint below, then space before the choices. The icon never changes + * (only the label does), so the mark is constant across the options. + */ +@Composable +private fun AppNamePreviewHero(name: LauncherName) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Surface( + shape = RoundedCornerShape(28.dp), + color = MaterialTheme.colorScheme.surfaceContainerHigh, + modifier = Modifier.fillMaxWidth(), + ) { + Column( + modifier = Modifier.padding(vertical = 28.dp, horizontal = 24.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + // The adaptive launcher mark, reconstructed as a squircle (as in + // the onboarding BrandHero) so it renders identically everywhere. + Box( + modifier = Modifier + .size(88.dp) + .clip(RoundedCornerShape(24.dp)) + .background(colorResource(R.color.ic_launcher_background)), + ) { + Image( + painter = painterResource(R.drawable.ic_launcher_foreground), + contentDescription = null, + modifier = Modifier.fillMaxSize(), + ) + } + Spacer(Modifier.height(16.dp)) + Text( + text = launcherNameLabel(name), + style = MaterialTheme.typography.titleLarge, + color = MaterialTheme.colorScheme.onSurface, + textAlign = TextAlign.Center, + ) + } + } + Spacer(Modifier.height(16.dp)) + Text( + text = stringResource(R.string.settings_app_name_summary), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center, + modifier = Modifier.padding(horizontal = 8.dp), + ) + Spacer(Modifier.height(24.dp)) + } +} + @Composable private fun themeLabel(mode: ThemeMode): String = stringResource( when (mode) { From 7d357bad87ea16e882685dfe59b72a25f7a3ed82 Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Sun, 19 Jul 2026 17:09:10 +0200 Subject: [PATCH 29/78] feat(settings): App name picker shows both names as preview cards MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the single current-state hero + option rows with two selectable launcher-mark cards (Calendula / Calendar), so the user sees what each choice would look like, not just the current name. Tapping a card applies immediately and highlights it (primary border + tinted container + check); the picker stays open so the change is visible, and back exits — which also fixes the earlier "hero doesn't update until reopened" gap. Built on FullScreenPicker directly (the component OptionPicker wraps) to render the custom card row; material-3 tokens throughout (surfaceContainerHigh / primary / primaryContainer / outlineVariant, 24dp corners, 8dp-grid spacing). Co-Authored-By: Claude Opus 4.8 (1M context) --- .../calendula/ui/settings/SettingsScreen.kt | 162 ++++++++++++------ 1 file changed, 106 insertions(+), 56 deletions(-) diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/SettingsScreen.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/SettingsScreen.kt index d7d2e12..3f4788c 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/SettingsScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/SettingsScreen.kt @@ -23,6 +23,8 @@ import androidx.compose.animation.slideInHorizontally import androidx.compose.animation.slideOutHorizontally import androidx.compose.foundation.Image import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -75,6 +77,7 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.colorResource @@ -698,19 +701,41 @@ private fun AppearanceScreen( } if (showAppName) { - OptionPicker( + FullScreenPicker( title = stringResource(R.string.settings_app_name), - predictiveBack = true, - options = LauncherName.entries, - selected = launcherName, - label = { launcherNameLabel(it) }, - onSelect = viewModel::setLauncherName, onDismiss = { showAppName = false }, - // A launcher-mark hero shows how the name reads under the icon, with - // the explanatory + icon-may-move hint, then breathing room above the - // choices. - header = { AppNamePreviewHero(launcherName) }, - ) + predictiveBack = true, + ) { + // Show both names as launcher-mark previews so the user sees what + // they'd switch to, not just the current state. Tapping applies + // immediately and highlights — the picker stays open so the change is + // visible; back exits. + Text( + text = stringResource(R.string.settings_app_name_summary), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 24.dp), + ) + Spacer(Modifier.height(24.dp)) + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + horizontalArrangement = Arrangement.spacedBy(16.dp), + ) { + LauncherName.entries.forEach { option -> + AppNameOptionCard( + name = option, + selected = launcherName == option, + onClick = { viewModel.setLauncherName(option) }, + modifier = Modifier.weight(1f), + ) + } + } + } } if (showTheme) { OptionPicker( @@ -1745,60 +1770,85 @@ private fun launcherNameLabel(name: LauncherName): String = stringResource( ) /** - * Header for the App name picker (issue #44): the app's launcher mark over the - * current name — a small preview of how the home screen reads — with the - * explanatory hint below, then space before the choices. The icon never changes - * (only the label does), so the mark is constant across the options. + * One selectable launcher-name preview in the App name picker (issue #44): the + * app's launcher mark over the name, framed as a card. The active one carries a + * primary border, a tinted container and a check; tapping selects it. The mark + * is the same for both — only the label changes — so the card previews exactly + * what the home screen will read. */ @Composable -private fun AppNamePreviewHero(name: LauncherName) { +private fun AppNameOptionCard( + name: LauncherName, + selected: Boolean, + onClick: () -> Unit, + modifier: Modifier = Modifier, +) { + val shape = RoundedCornerShape(24.dp) + val borderColor = if (selected) { + MaterialTheme.colorScheme.primary + } else { + MaterialTheme.colorScheme.outlineVariant + } + val containerColor = if (selected) { + MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.4f) + } else { + MaterialTheme.colorScheme.surfaceContainerHigh + } Column( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 16.dp), + modifier = modifier + .clip(shape) + .background(containerColor) + .border(width = if (selected) 2.dp else 1.dp, color = borderColor, shape = shape) + .clickable(onClick = onClick) + .padding(vertical = 20.dp, horizontal = 16.dp), horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(12.dp), ) { - Surface( - shape = RoundedCornerShape(28.dp), - color = MaterialTheme.colorScheme.surfaceContainerHigh, - modifier = Modifier.fillMaxWidth(), + // The adaptive launcher mark, reconstructed as a squircle (as in the + // onboarding BrandHero) so it renders identically everywhere. + Box( + modifier = Modifier + .size(64.dp) + .clip(RoundedCornerShape(18.dp)) + .background(colorResource(R.color.ic_launcher_background)), ) { - Column( - modifier = Modifier.padding(vertical = 28.dp, horizontal = 24.dp), - horizontalAlignment = Alignment.CenterHorizontally, - ) { - // The adaptive launcher mark, reconstructed as a squircle (as in - // the onboarding BrandHero) so it renders identically everywhere. - Box( - modifier = Modifier - .size(88.dp) - .clip(RoundedCornerShape(24.dp)) - .background(colorResource(R.color.ic_launcher_background)), - ) { - Image( - painter = painterResource(R.drawable.ic_launcher_foreground), - contentDescription = null, - modifier = Modifier.fillMaxSize(), - ) - } - Spacer(Modifier.height(16.dp)) - Text( - text = launcherNameLabel(name), - style = MaterialTheme.typography.titleLarge, - color = MaterialTheme.colorScheme.onSurface, - textAlign = TextAlign.Center, + Image( + painter = painterResource(R.drawable.ic_launcher_foreground), + contentDescription = null, + modifier = Modifier.fillMaxSize(), + ) + } + Text( + text = launcherNameLabel(name), + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.onSurface, + textAlign = TextAlign.Center, + maxLines = 1, + ) + // Selection indicator: a filled check when active, an empty ring otherwise. + Box( + modifier = Modifier + .size(24.dp) + .clip(CircleShape) + .background(if (selected) MaterialTheme.colorScheme.primary else Color.Transparent) + .then( + if (selected) { + Modifier + } else { + Modifier.border(1.dp, MaterialTheme.colorScheme.outlineVariant, CircleShape) + }, + ), + contentAlignment = Alignment.Center, + ) { + if (selected) { + Icon( + imageVector = Icons.Filled.Check, + contentDescription = null, + tint = MaterialTheme.colorScheme.onPrimary, + modifier = Modifier.size(16.dp), ) } } - Spacer(Modifier.height(16.dp)) - Text( - text = stringResource(R.string.settings_app_name_summary), - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - textAlign = TextAlign.Center, - modifier = Modifier.padding(horizontal = 8.dp), - ) - Spacer(Modifier.height(24.dp)) } } From 30fcbfa59fcba2a99d5a2499562c37864fe782d5 Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Sun, 19 Jul 2026 16:22:07 +0200 Subject: [PATCH 30/78] fix(widget): scale the agenda widget with its size (#51) The "Upcoming" agenda widget used Glance's default SizeMode.Single: it was composed once at the minimum size and the launcher stretched that single RemoteViews when enlarged, so the text stayed small-widget-sized no matter how big the widget grew. Reported as a "font size" request (#51), but it's really a missing size-response. Switch to SizeMode.Exact (like MonthWidget) and read LocalSize.current to pick one of four tiers (COMPACT/REGULAR/LARGE/XLARGE), scaling type and row metrics. Exact over Responsive so the ~30-day LazyColumn isn't replicated per tier. Width picks the tier, height can only lower it. Width governs how much of a title fits on a row, so it's what should drive type size; height only decides how many rows are visible, so a tall narrow widget shows more events rather than bigger text. Height does act as a cap, though, or a squashed widget would keep the large type its width earned in a sliver of space. Thresholds are spread over the width range a phone actually produces (measured on a Pixel/Nova: a compact widget is 222dp wide, a large one 378dp) rather than a theoretical range, so the tiers are reachable in practice; XLARGE is reserved for tablets/foldables. COMPACT reproduces the original constants verbatim, so an existing widget is visually unchanged. The tier logic lives in a pure, Glance-free AgendaScale.kt (compose.ui.unit only) and is JVM-tested: the COMPACT baseline, the width buckets, the height cap stepping a squashed widget down, and that height never raises the tier. No new setting: the widget follows the size the launcher/user already chose. Co-Authored-By: Claude Opus 4.8 (1M context) --- CHANGELOG.md | 9 ++ .../calendula/widget/agenda/AgendaScale.kt | 129 ++++++++++++++++++ .../calendula/widget/agenda/AgendaWidget.kt | 62 ++++++--- .../widget/agenda/AgendaScaleTest.kt | 105 ++++++++++++++ 4 files changed, 284 insertions(+), 21 deletions(-) create mode 100644 app/src/main/java/de/jeanlucmakiola/calendula/widget/agenda/AgendaScale.kt create mode 100644 app/src/test/java/de/jeanlucmakiola/calendula/widget/agenda/AgendaScaleTest.kt diff --git a/CHANGELOG.md b/CHANGELOG.md index 9e901b4..d807ca8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Fixed +- The "Upcoming" agenda widget now scales its text and rows to the size you give + it. Previously it was laid out once for the smallest size and simply stretched + when enlarged, so the text stayed small no matter how big you made the widget. + Now a bigger widget gets bigger, more readable type and roomier rows, while the + default size looks exactly as before — no new setting; it follows the size you + already chose ([#51]). + ## [2.16.0] — 2026-07-17 ### Added @@ -1038,5 +1046,6 @@ automatically, with zero telemetry and no internet permission. [#47]: https://codeberg.org/jlmakiola/calendula/issues/47 [#48]: https://codeberg.org/jlmakiola/calendula/issues/48 [#49]: https://codeberg.org/jlmakiola/calendula/issues/49 +[#51]: https://codeberg.org/jlmakiola/calendula/issues/51 [#52]: https://codeberg.org/jlmakiola/calendula/issues/52 [#60]: https://codeberg.org/jlmakiola/calendula/issues/60 diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/widget/agenda/AgendaScale.kt b/app/src/main/java/de/jeanlucmakiola/calendula/widget/agenda/AgendaScale.kt new file mode 100644 index 0000000..5e1880d --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/calendula/widget/agenda/AgendaScale.kt @@ -0,0 +1,129 @@ +package de.jeanlucmakiola.calendula.widget.agenda + +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.DpSize +import androidx.compose.ui.unit.TextUnit +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp + +/** + * Size tiers the agenda widget scales its typography and row metrics across (#51). + * + * The widget uses [androidx.glance.appwidget.SizeMode.Exact], so the composition + * sees the widget's live size via `LocalSize.current`; [scaleFor] buckets that + * into one of these tiers and [metricsFor] returns the sizes to draw with. Kept + * in a pure, Glance-free file (only `compose.ui.unit`) so the bucketing and the + * "default size is unchanged" invariant are covered by plain JVM tests. + */ +internal enum class AgendaScale { COMPACT, REGULAR, LARGE, XLARGE } + +/** + * Every size the agenda widget varies by tier. Values that don't need to grow + * (horizontal paddings, the 5.dp stripe width, corner radii) stay inline literals + * in the widget rather than routing through here. + */ +internal data class AgendaMetrics( + val title: TextUnit, // header "Upcoming" + val dayHeader: TextUnit, // day label ("Today · …") + val eventTitle: TextUnit, // event title line + val eventTime: TextUnit, // time/location line + val placeholder: TextUnit, // "no more events today" (#35) + val message: TextUnit, // empty/permission centred message + val stripeH: Dp, // coloured event stripe height + val iconImage: Dp, // header action icon glyph + val iconBox: Dp, // header action touch target + val rowVPad: Dp, // event row vertical padding +) + +/** + * [AgendaMetrics] per tier. [AgendaScale.COMPACT] reproduces the widget's original + * hardcoded constants **verbatim** — so at the default/small size nothing changes; + * a JVM test pins this against the baseline. Larger tiers scale up by roughly + * 1.12× / 1.28× / 1.45× (starting points to refine on-device). + */ +internal fun metricsFor(scale: AgendaScale): AgendaMetrics = when (scale) { + AgendaScale.COMPACT -> AgendaMetrics( + title = 16.sp, + dayHeader = 13.sp, + eventTitle = 14.sp, + eventTime = 12.sp, + placeholder = 14.sp, + message = 14.sp, + stripeH = 36.dp, + iconImage = 22.dp, + iconBox = 40.dp, + rowVPad = 4.dp, + ) + AgendaScale.REGULAR -> AgendaMetrics( + title = 18.sp, + dayHeader = 14.sp, + eventTitle = 15.sp, + eventTime = 13.sp, + placeholder = 15.sp, + message = 15.sp, + stripeH = 40.dp, + iconImage = 24.dp, + iconBox = 44.dp, + rowVPad = 5.dp, + ) + AgendaScale.LARGE -> AgendaMetrics( + title = 20.sp, + dayHeader = 16.sp, + eventTitle = 17.sp, + eventTime = 14.sp, + placeholder = 16.sp, + message = 16.sp, + stripeH = 46.dp, + iconImage = 26.dp, + iconBox = 48.dp, + rowVPad = 6.dp, + ) + AgendaScale.XLARGE -> AgendaMetrics( + title = 22.sp, + dayHeader = 18.sp, + eventTitle = 19.sp, + eventTime = 15.sp, + placeholder = 18.sp, + message = 18.sp, + stripeH = 52.dp, + iconImage = 28.dp, + iconBox = 52.dp, + rowVPad = 8.dp, + ) +} + +/** + * Buckets a live widget size into an [AgendaScale] by **width**. + * + * Width is the right axis: it governs how much of a title fits on a row, so it's + * what should drive type size. Height only decides how many rows are visible — a + * tall, narrow widget wants *more events*, not bigger text — so it never raises + * the tier. It does act as a **cap**, though: a very short widget is stepped back + * down so it can't keep oversized type in a sliver of space. + * + * The thresholds are spread across the width range a phone can actually produce + * (~180dp up to roughly the screen width) rather than over a theoretical range, so + * the tiers are reachable in practice. Calibrated on-device (Pixel / Nova): a + * compact 222dp-wide widget stays COMPACT (the app's baseline, unchanged) and a + * large 378dp-wide one reaches LARGE. XLARGE is reserved for genuinely wide + * surfaces — tablets, foldables, landscape — where the extra size reads well. + */ +internal fun scaleFor(size: DpSize): AgendaScale { + val byWidth = when { + size.width < 260.dp -> AgendaScale.COMPACT + size.width < 330.dp -> AgendaScale.REGULAR + size.width < 420.dp -> AgendaScale.LARGE + else -> AgendaScale.XLARGE + } + // Height can only ever pull the tier *down*, never push it up: a squashed + // widget would otherwise keep the big type its width earned and look absurd + // in the little space left. Keeping this a cap (rather than a second scaling + // axis) is what preserves "tall and narrow shows more events, not bigger text". + val heightCap = when { + size.height < 220.dp -> AgendaScale.COMPACT + size.height < 320.dp -> AgendaScale.REGULAR + size.height < 420.dp -> AgendaScale.LARGE + else -> AgendaScale.XLARGE + } + return minOf(byWidth, heightCap) +} diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/widget/agenda/AgendaWidget.kt b/app/src/main/java/de/jeanlucmakiola/calendula/widget/agenda/AgendaWidget.kt index 198a21a..c2fe4de 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/widget/agenda/AgendaWidget.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/widget/agenda/AgendaWidget.kt @@ -5,7 +5,6 @@ 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.booleanPreferencesKey import androidx.datastore.preferences.core.stringPreferencesKey import androidx.glance.ColorFilter @@ -14,9 +13,11 @@ import androidx.glance.GlanceModifier import androidx.glance.GlanceTheme import androidx.glance.Image import androidx.glance.ImageProvider +import androidx.glance.LocalSize import androidx.glance.action.ActionParameters import androidx.glance.action.clickable import androidx.glance.appwidget.GlanceAppWidget +import androidx.glance.appwidget.SizeMode import androidx.glance.appwidget.action.ActionCallback import androidx.glance.appwidget.action.actionRunCallback import androidx.glance.appwidget.action.actionStartActivity @@ -107,6 +108,13 @@ class AgendaWidget : GlanceAppWidget() { override val stateDefinition = PreferencesGlanceStateDefinition + // Exact (not Responsive) so a single copy of the day/event list is laid out at + // the widget's live size — Responsive would replicate the whole LazyColumn per + // declared tier. The composition reads LocalSize.current and scales type/rows + // from it ([scaleFor]/[metricsFor]); at the default size that resolves to + // COMPACT, i.e. the layout is unchanged (#51). + override val sizeMode = SizeMode.Exact + override suspend fun provideGlance(context: Context, id: GlanceId) { val data = context.loadAgendaWidgetData() val dark = (context.resources.configuration.uiMode and @@ -136,16 +144,19 @@ private sealed interface AgendaRow { @Composable private fun AgendaWidgetBody(data: AgendaWidgetData, dark: Boolean) { + // Type and row metrics scale with the widget's live size (SizeMode.Exact); a + // short/compact widget resolves to COMPACT, leaving the layout unchanged (#51). + val metrics = metricsFor(scaleFor(LocalSize.current)) Column( modifier = GlanceModifier .fillMaxSize() .background(GlanceTheme.colors.surface) .padding(horizontal = 8.dp, vertical = 6.dp), ) { - AgendaHeader() + AgendaHeader(metrics) Spacer(GlanceModifier.height(4.dp)) when (data) { - AgendaWidgetData.NeedsPermission -> WidgetMessage(R.string.widget_needs_permission) + AgendaWidgetData.NeedsPermission -> WidgetMessage(R.string.widget_needs_permission, metrics) is AgendaWidgetData.Ready -> { // Range read reactively from per-instance Glance state (falls back // to the saved pref for a freshly placed widget), then the wide @@ -179,7 +190,7 @@ private fun AgendaWidgetBody(data: AgendaWidgetData, dark: Boolean) { enabled = showToday, ) if (visibleDays.isEmpty()) { - WidgetMessage(R.string.agenda_empty_title) + WidgetMessage(R.string.agenda_empty_title, metrics) } else { val rows = buildList { visibleDays.forEach { day -> @@ -194,8 +205,8 @@ private fun AgendaWidgetBody(data: AgendaWidgetData, dark: Boolean) { LazyColumn(modifier = GlanceModifier.fillMaxSize()) { items(rows.size) { index -> when (val row = rows[index]) { - is AgendaRow.Header -> DayHeaderRow(row.date, row.today) - is AgendaRow.Placeholder -> PlaceholderRow(row.date) + is AgendaRow.Header -> DayHeaderRow(row.date, row.today, metrics) + is AgendaRow.Placeholder -> PlaceholderRow(row.date, metrics) is AgendaRow.Event -> EventRow( event = row.event, day = row.date, @@ -204,6 +215,7 @@ private fun AgendaWidgetBody(data: AgendaWidgetData, dark: Boolean) { is24Hour = data.is24Hour, dimmed = pastDisplay == PastEventDisplay.DIM && row.event.hasEnded(data.now), + metrics = metrics, ) } } @@ -215,7 +227,7 @@ private fun AgendaWidgetBody(data: AgendaWidgetData, dark: Boolean) { } @Composable -private fun AgendaHeader() { +private fun AgendaHeader(metrics: AgendaMetrics) { val context = androidx.glance.LocalContext.current Row( modifier = GlanceModifier.fillMaxWidth().padding(horizontal = 4.dp), @@ -227,7 +239,7 @@ private fun AgendaHeader() { text = context.getString(R.string.widget_agenda_title), style = TextStyle( color = GlanceTheme.colors.primary, - fontSize = 16.sp, + fontSize = metrics.title, fontWeight = FontWeight.Medium, ), modifier = GlanceModifier @@ -240,6 +252,7 @@ private fun AgendaHeader() { resId = R.drawable.ic_widget_refresh, contentDescription = context.getString(R.string.widget_refresh), onClick = GlanceModifier.clickable(actionRunCallback()), + metrics = metrics, ) IconButton( resId = R.drawable.ic_widget_add, @@ -249,34 +262,40 @@ private fun AgendaHeader() { MainActivity.openCreateIntent(context, today(systemZone())), ), ), + metrics = metrics, ) } } @Composable -private fun IconButton(resId: Int, contentDescription: String, onClick: GlanceModifier) { +private fun IconButton( + resId: Int, + contentDescription: String, + onClick: GlanceModifier, + metrics: AgendaMetrics, +) { Box( - modifier = GlanceModifier.size(40.dp).then(onClick), + modifier = GlanceModifier.size(metrics.iconBox).then(onClick), contentAlignment = Alignment.Center, ) { Image( provider = ImageProvider(resId), contentDescription = contentDescription, colorFilter = ColorFilter.tint(GlanceTheme.colors.onSurfaceVariant), - modifier = GlanceModifier.size(22.dp), + modifier = GlanceModifier.size(metrics.iconImage), ) } } @Composable -private fun DayHeaderRow(date: LocalDate, today: LocalDate) { +private fun DayHeaderRow(date: LocalDate, today: LocalDate, metrics: AgendaMetrics) { 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, + fontSize = metrics.dayHeader, fontWeight = FontWeight.Medium, ), modifier = GlanceModifier @@ -296,11 +315,11 @@ private fun DayHeaderRow(date: LocalDate, today: LocalDate) { * plain too (a stripe + text, not cards), so a card here would look out of place. */ @Composable -private fun PlaceholderRow(date: LocalDate) { +private fun PlaceholderRow(date: LocalDate, metrics: AgendaMetrics) { val context = androidx.glance.LocalContext.current Text( text = context.getString(R.string.agenda_no_more_today), - style = TextStyle(color = GlanceTheme.colors.onSurfaceVariant, fontSize = 14.sp), + style = TextStyle(color = GlanceTheme.colors.onSurfaceVariant, fontSize = metrics.placeholder), modifier = GlanceModifier .fillMaxWidth() .padding(start = 19.dp, end = 8.dp, top = 2.dp, bottom = 6.dp) @@ -320,6 +339,7 @@ private fun EventRow( soften: Boolean, is24Hour: Boolean, dimmed: Boolean, + metrics: AgendaMetrics, ) { val context = androidx.glance.LocalContext.current val title = event.title.ifBlank { context.getString(R.string.event_untitled) } @@ -332,7 +352,7 @@ private fun EventRow( Row( modifier = GlanceModifier .fillMaxWidth() - .padding(horizontal = 4.dp, vertical = 4.dp) + .padding(horizontal = 4.dp, vertical = metrics.rowVPad) .clickable( actionStartActivity( MainActivity.openEventIntent( @@ -349,7 +369,7 @@ private fun EventRow( Box( modifier = GlanceModifier .width(5.dp) - .height(36.dp) + .height(metrics.stripeH) .cornerRadius(3.dp) .background(stripeColor), ) {} @@ -358,19 +378,19 @@ private fun EventRow( Text( text = title, maxLines = 1, - style = TextStyle(color = titleColor, fontSize = 14.sp), + style = TextStyle(color = titleColor, fontSize = metrics.eventTitle), ) Text( text = eventTimeSummary(context, event, day, is24Hour), maxLines = 1, - style = TextStyle(color = GlanceTheme.colors.onSurfaceVariant, fontSize = 12.sp), + style = TextStyle(color = GlanceTheme.colors.onSurfaceVariant, fontSize = metrics.eventTime), ) } } } @Composable -private fun WidgetMessage(resId: Int) { +private fun WidgetMessage(resId: Int, metrics: AgendaMetrics) { val context = androidx.glance.LocalContext.current Box( modifier = GlanceModifier.fillMaxSize().padding(16.dp), @@ -378,7 +398,7 @@ private fun WidgetMessage(resId: Int) { ) { Text( text = context.getString(resId), - style = TextStyle(color = GlanceTheme.colors.onSurfaceVariant, fontSize = 14.sp), + style = TextStyle(color = GlanceTheme.colors.onSurfaceVariant, fontSize = metrics.message), ) } } diff --git a/app/src/test/java/de/jeanlucmakiola/calendula/widget/agenda/AgendaScaleTest.kt b/app/src/test/java/de/jeanlucmakiola/calendula/widget/agenda/AgendaScaleTest.kt new file mode 100644 index 0000000..6ce534e --- /dev/null +++ b/app/src/test/java/de/jeanlucmakiola/calendula/widget/agenda/AgendaScaleTest.kt @@ -0,0 +1,105 @@ +package de.jeanlucmakiola.calendula.widget.agenda + +import androidx.compose.ui.unit.DpSize +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.google.common.truth.Truth.assertThat +import org.junit.jupiter.api.Test + +class AgendaScaleTest { + + // --- scaleFor: bucketing ------------------------------------------------- + + @Test + fun `the on-device calibration points map to their tiers`() { + // The two sizes measured on-device: the compact widget stays COMPACT (the + // baseline, unchanged), the large one steps up to LARGE — not XLARGE, + // which read as too big on a phone (#51). + assertThat(scaleFor(DpSize(222.dp, 270.dp))).isEqualTo(AgendaScale.COMPACT) + assertThat(scaleFor(DpSize(378.dp, 672.dp))).isEqualTo(AgendaScale.LARGE) + } + + @Test + fun `width buckets into the four tiers`() { + // Tall enough that the height cap never binds, isolating the width rule. + val h = 500.dp + assertThat(scaleFor(DpSize(180.dp, h))).isEqualTo(AgendaScale.COMPACT) + assertThat(scaleFor(DpSize(259.dp, h))).isEqualTo(AgendaScale.COMPACT) + assertThat(scaleFor(DpSize(260.dp, h))).isEqualTo(AgendaScale.REGULAR) + assertThat(scaleFor(DpSize(329.dp, h))).isEqualTo(AgendaScale.REGULAR) + assertThat(scaleFor(DpSize(330.dp, h))).isEqualTo(AgendaScale.LARGE) + assertThat(scaleFor(DpSize(419.dp, h))).isEqualTo(AgendaScale.LARGE) + assertThat(scaleFor(DpSize(420.dp, h))).isEqualTo(AgendaScale.XLARGE) + assertThat(scaleFor(DpSize(900.dp, h))).isEqualTo(AgendaScale.XLARGE) + } + + @Test + fun `extra height never raises the tier`() { + // Height decides how many rows are visible, not how big they are: a tall, + // narrow widget wants more events, not bigger text. + val short = scaleFor(DpSize(222.dp, 200.dp)) + val tall = scaleFor(DpSize(222.dp, 900.dp)) + assertThat(short).isEqualTo(AgendaScale.COMPACT) + assertThat(tall).isEqualTo(AgendaScale.COMPACT) + } + + @Test + fun `a squashed widget is stepped back down`() { + // Same (wide) width, shrinking height: the tier must walk down rather than + // keep big type in a sliver of space. + val wide = 378.dp + assertThat(scaleFor(DpSize(wide, 672.dp))).isEqualTo(AgendaScale.LARGE) + assertThat(scaleFor(DpSize(wide, 400.dp))).isEqualTo(AgendaScale.LARGE) + assertThat(scaleFor(DpSize(wide, 300.dp))).isEqualTo(AgendaScale.REGULAR) + assertThat(scaleFor(DpSize(wide, 200.dp))).isEqualTo(AgendaScale.COMPACT) + } + + @Test + fun `the height cap never exceeds what the width earned`() { + // A tall but narrow widget stays at its width's tier — the cap only ever + // lowers, so a huge height can't promote a 222dp-wide widget. + assertThat(scaleFor(DpSize(222.dp, 900.dp))).isEqualTo(AgendaScale.COMPACT) + assertThat(scaleFor(DpSize(300.dp, 900.dp))).isEqualTo(AgendaScale.REGULAR) + } + + // --- metricsFor: the "default unchanged" regression guard ----------------- + + @Test + fun `COMPACT metrics equal the widget's original constants`() { + // If this fails, a default-sized agenda widget no longer looks as it did. + val m = metricsFor(AgendaScale.COMPACT) + assertThat(m.title).isEqualTo(16.sp) + assertThat(m.dayHeader).isEqualTo(13.sp) + assertThat(m.eventTitle).isEqualTo(14.sp) + assertThat(m.eventTime).isEqualTo(12.sp) + assertThat(m.placeholder).isEqualTo(14.sp) + assertThat(m.message).isEqualTo(14.sp) + assertThat(m.stripeH).isEqualTo(36.dp) + assertThat(m.iconImage).isEqualTo(22.dp) + assertThat(m.iconBox).isEqualTo(40.dp) + assertThat(m.rowVPad).isEqualTo(4.dp) + } + + @Test + fun `type sizes are non-decreasing across the tiers`() { + val tiers = listOf( + AgendaScale.COMPACT, + AgendaScale.REGULAR, + AgendaScale.LARGE, + AgendaScale.XLARGE, + ).map(::metricsFor) + + tiers.zipWithNext { small, big -> + assertThat(big.title.value).isAtLeast(small.title.value) + assertThat(big.dayHeader.value).isAtLeast(small.dayHeader.value) + assertThat(big.eventTitle.value).isAtLeast(small.eventTitle.value) + assertThat(big.eventTime.value).isAtLeast(small.eventTime.value) + assertThat(big.placeholder.value).isAtLeast(small.placeholder.value) + assertThat(big.message.value).isAtLeast(small.message.value) + assertThat(big.stripeH.value).isAtLeast(small.stripeH.value) + assertThat(big.iconImage.value).isAtLeast(small.iconImage.value) + assertThat(big.iconBox.value).isAtLeast(small.iconBox.value) + assertThat(big.rowVPad.value).isAtLeast(small.rowVPad.value) + } + } +} From 722fc8725935ac45634c6949cf8ca95b6156f045 Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Mon, 20 Jul 2026 13:04:57 +0200 Subject: [PATCH 31/78] fix(edit): honour week-start pref in recurrence picker + smooth weekday collapse MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - The weekday toggles now order by the app's "Week starts on" setting (via WeekStartPref.resolveFirstDay), matching the month/week/agenda views, instead of always following the device locale. Threaded through a new EventEditViewModel.firstDayOfWeek flow. - Carry the 16dp section gaps on each block rather than a parent spacedBy, so the weekday card's gap collapses together with the card under AnimatedVisibility — removing the end-of-animation jump in the ends section when switching away from Weekly. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../calendula/ui/edit/EventEditScreen.kt | 27 +++++++++++-------- .../calendula/ui/edit/EventEditViewModel.kt | 17 ++++++++++++ 2 files changed, 33 insertions(+), 11 deletions(-) diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/edit/EventEditScreen.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/edit/EventEditScreen.kt index 5eeee1f..aa569b0 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/edit/EventEditScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/edit/EventEditScreen.kt @@ -163,14 +163,12 @@ import kotlinx.datetime.TimeZone import kotlinx.datetime.isoDayNumber import kotlinx.datetime.toJavaDayOfWeek import kotlinx.datetime.toJavaLocalDate -import kotlinx.datetime.toKotlinDayOfWeek import kotlinx.datetime.toJavaLocalTime import kotlinx.datetime.toLocalDateTime import java.time.ZoneId import java.time.format.DateTimeFormatter import java.time.format.FormatStyle import java.time.format.TextStyle as JavaTextStyle -import java.time.temporal.WeekFields import java.util.Locale import kotlin.time.Clock @@ -502,6 +500,7 @@ private fun EventEditContent( ) { val form = state.form val locale = currentLocale() + val firstDayOfWeek by viewModel.firstDayOfWeek.collectAsStateWithLifecycle() val dark = isSystemInDarkTheme() // The title, date and recurrence are managed by the special-dates sync, so // they're locked here; everything else (reminders, location, notes) is the @@ -1122,6 +1121,7 @@ private fun EventEditContent( RecurrencePickerDialog( current = form.rrule, startDay = form.start.date.dayOfWeek, + firstDayOfWeek = firstDayOfWeek, onSelect = { rrule -> viewModel.setRecurrence(rrule) showRecurrencePicker = false @@ -1293,6 +1293,7 @@ private enum class RecurrenceEndMode { Never, Until, Count } private fun RecurrencePickerDialog( current: String?, startDay: DayOfWeek, + firstDayOfWeek: DayOfWeek, onSelect: (String?) -> Unit, onDismiss: () -> Unit, ) { @@ -1381,10 +1382,10 @@ private fun RecurrencePickerDialog( onClick = { customMode = true }, ) } else { - Column( - modifier = Modifier.fillMaxWidth(), - verticalArrangement = Arrangement.spacedBy(16.dp), - ) { + // Section gaps live on each block (not a parent spacedBy) so the + // weekday card's gap collapses *with* it under AnimatedVisibility — + // a parent arrangement would leave a residual gap that snaps shut. + Column(modifier = Modifier.fillMaxWidth()) { // A live, human-readable read-out of the rule being built, so the // effect of the controls below is never a guess. Text( @@ -1414,6 +1415,7 @@ private fun RecurrencePickerDialog( shape = groupedShape(Position.Alone), modifier = Modifier .fillMaxWidth() + .padding(top = 16.dp) .padding(horizontal = 16.dp), ) { Column( @@ -1447,19 +1449,22 @@ private fun RecurrencePickerDialog( } } - // Weekday picks — only meaningful on a weekly rule. + // Weekday picks — only meaningful on a weekly rule. The 16dp gap + // sits inside the animated block so it grows/shrinks with the card. AnimatedVisibility(visible = freq == RecurrenceFreq.Weekly) { Surface( color = MaterialTheme.colorScheme.surfaceContainerHigh, shape = groupedShape(Position.Alone), modifier = Modifier .fillMaxWidth() + .padding(top = 16.dp) .padding(horizontal = 16.dp), ) { WeekdayToggleRow( selected = daysMask.toDaySet(), onToggle = { day -> daysMask = daysMask xor day.toMaskBit() }, locale = locale, + firstDay = firstDayOfWeek, modifier = Modifier.padding(16.dp), ) } @@ -1467,7 +1472,7 @@ private fun RecurrencePickerDialog( // Ends: a connected never / on-a-date / after-N group, the count // field folding into the bottom of the run when chosen. - Column { + Column(modifier = Modifier.padding(top = 16.dp)) { Text( text = stringResource(R.string.event_edit_recurrence_ends), style = MaterialTheme.typography.labelLarge, @@ -1559,11 +1564,11 @@ private fun WeekdayToggleRow( selected: Set, onToggle: (DayOfWeek) -> Unit, locale: Locale, + firstDay: DayOfWeek, modifier: Modifier = Modifier, ) { - val days = remember(locale) { - val first = WeekFields.of(locale).firstDayOfWeek.toKotlinDayOfWeek() - (0 until 7).map { DayOfWeek(((first.isoDayNumber - 1 + it) % 7) + 1) } + val days = remember(firstDay) { + (0 until 7).map { DayOfWeek(((firstDay.isoDayNumber - 1 + it) % 7) + 1) } } Row( horizontalArrangement = Arrangement.SpaceBetween, diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/edit/EventEditViewModel.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/edit/EventEditViewModel.kt index b339f39..dc4e469 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/edit/EventEditViewModel.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/edit/EventEditViewModel.kt @@ -8,7 +8,9 @@ import de.jeanlucmakiola.calendula.data.calendar.NoSuchEventException import de.jeanlucmakiola.calendula.data.di.IoDispatcher import de.jeanlucmakiola.calendula.data.prefs.CalendarPrefs import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs +import de.jeanlucmakiola.calendula.data.prefs.WeekStartPref import de.jeanlucmakiola.calendula.data.prefs.resolveDefaultReminder +import de.jeanlucmakiola.calendula.data.prefs.resolveFirstDay import de.jeanlucmakiola.calendula.domain.AccessLevel import de.jeanlucmakiola.calendula.domain.Availability import de.jeanlucmakiola.calendula.domain.CalendarSource @@ -40,12 +42,14 @@ import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch import kotlinx.coroutines.withContext +import kotlinx.datetime.DayOfWeek import kotlinx.datetime.LocalDate import kotlinx.datetime.LocalDateTime import kotlinx.datetime.LocalTime import kotlinx.datetime.TimeZone import kotlinx.datetime.toInstant import kotlinx.datetime.toLocalDateTime +import java.util.Locale import kotlin.coroutines.cancellation.CancellationException import kotlin.time.Clock import kotlin.time.Duration.Companion.hours @@ -267,6 +271,19 @@ class EventEditViewModel @Inject constructor( initialValue = null, ) + /** + * First day of the week for ordering the recurrence weekday toggles — the + * app's "week starts on" preference, matching the calendar views (Auto + * falls back to the locale convention). + */ + val firstDayOfWeek: StateFlow = settingsPrefs.weekStart + .map { it.resolveFirstDay(Locale.getDefault()) } + .stateIn( + scope = viewModelScope, + started = SharingStarted.WhileSubscribed(5_000L), + initialValue = WeekStartPref.Auto.resolveFirstDay(Locale.getDefault()), + ) + /** * Initialise a fresh form for a new event on [date]. [startMinutes] (minutes * from midnight) anchors the start when the form is opened by tapping a slot From 94355bf340cd259000a0558bdbebbc4e7c63e137 Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Mon, 20 Jul 2026 13:10:39 +0200 Subject: [PATCH 32/78] fix: address code-review findings on the 2.16.0 branch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Correctness: - Recurring writes: move the series DTSTART by the *wall-clock* shift applied to the edited occurrence and re-resolve it in the event's zone, instead of by a millisecond delta. The old delta baked in whichever UTC offset applied on the edited occurrence's date, so pinning a recurring event to another zone — or editing an occurrence on the far side of a DST boundary from the series anchor — shifted the whole series by an hour. Also snaps the anchor to a UTC midnight when the event becomes all-day. - Detail card and edit form now resolve a pinned zone's abbreviation/offset at the *event's* instant, not at "now", so a July event no longer reads "CET · 10:00 AM" when opened in January. - Agenda: the zone used to label multi-day rows now travels on AgendaUiState.Success rather than a process-lifetime file-level constant, so labelling can't disagree with the grouping after a device time-zone change. - Week title: spell out the year when the week straddles New Year, via a new forceYear flag on formatCalendarTitle. Performance: - Build the ~600-entry zone catalogue off the main thread (produceState + Dispatchers.Default); resolve the device row's summary on its own so it still renders complete on the first frame. - Pre-normalize each TimeZoneOption's search keys at construction, turning ~2400 NFD normalizations per keystroke into plain prefix/substring checks. Hoist the combining-mark Regex out of the hot path. - Key the edit form's local-time line on the fields it reads instead of recomputing it on every keystroke. - Move LauncherNameManager's PackageManager binder calls off the main thread. Cleanup: - Drop a duplicate Public icon import and the unused event_edit_timezone_clear string. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../data/calendar/EventWriteMapper.kt | 57 +++++++++++-- .../calendula/domain/TimeZoneCatalog.kt | 59 +++++++++---- .../calendula/ui/agenda/AgendaScreen.kt | 35 +++++--- .../calendula/ui/agenda/AgendaUiState.kt | 7 ++ .../calendula/ui/agenda/AgendaViewModel.kt | 1 + .../calendula/ui/common/CalendarTitle.kt | 8 +- .../calendula/ui/common/TimeZonePicker.kt | 37 +++++--- .../calendula/ui/detail/EventDetailScreen.kt | 24 +++++- .../calendula/ui/edit/EventEditScreen.kt | 27 +++++- .../ui/settings/SettingsViewModel.kt | 31 +++++-- .../calendula/ui/week/WeekScreen.kt | 16 +++- app/src/main/res/values/strings.xml | 1 - .../data/calendar/EventWriteMapperTest.kt | 85 ++++++++++++++++++- 13 files changed, 324 insertions(+), 64 deletions(-) diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/EventWriteMapper.kt b/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/EventWriteMapper.kt index 4053588..8e64db5 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/EventWriteMapper.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/EventWriteMapper.kt @@ -6,9 +6,11 @@ import de.jeanlucmakiola.calendula.domain.Availability import de.jeanlucmakiola.calendula.domain.EventForm import kotlinx.datetime.toJavaLocalDate import kotlinx.datetime.toJavaLocalDateTime +import java.time.Duration import java.time.Instant import java.time.ZoneId import java.time.ZoneOffset +import java.time.LocalDateTime as JavaLocalDateTime /** Provider-ready DTSTART / DTEND / EVENT_TIMEZONE for an event write. */ internal data class EventWriteTimes( @@ -35,7 +37,7 @@ internal fun EventForm.toWriteTimes(zone: ZoneId): EventWriteTimes = if (isAllDa timezone = "UTC", ) } else { - val writeZone = timezone?.let { runCatching { ZoneId.of(it) }.getOrNull() } ?: zone + val writeZone = writeZone(zone) EventWriteTimes( dtStartMillis = start.toJavaLocalDateTime().atZone(writeZone).toInstant().toEpochMilli(), dtEndMillis = end.toJavaLocalDateTime().atZone(writeZone).toInstant().toEpochMilli(), @@ -43,6 +45,30 @@ internal fun EventForm.toWriteTimes(zone: ZoneId): EventWriteTimes = if (isAllDa ) } +/** + * The zone this form's DTSTART is expressed in: UTC for an all-day event (the + * provider's date anchor), otherwise the form's own pinned zone, falling back to + * [deviceZone] when it doesn't pin one or pins something the tz database can't + * parse. + */ +private fun EventForm.writeZone(deviceZone: ZoneId): ZoneId = if (isAllDay) { + ZoneOffset.UTC +} else { + timezone?.let { runCatching { ZoneId.of(it) }.getOrNull() } ?: deviceZone +} + +/** + * The form's start as a bare wall-clock value — what the user sees on the form, + * stripped of any zone. All-day events use their date's midnight rather than + * [EventForm.start]'s placeholder time-of-day, which exists only so switching the + * event back to timed has something to show. + */ +private fun EventForm.anchorLocal(): JavaLocalDateTime = if (isAllDay) { + start.date.toJavaLocalDate().atStartOfDay() +} else { + start.toJavaLocalDateTime() +} + /** * RFC 2445 duration for a recurring event's row (the provider requires * DURATION instead of DTEND when an RRULE is set): whole days for all-day @@ -109,10 +135,12 @@ internal fun buildEventInsertValues( * Time fields travel together (the provider validates them as a unit): * - unchanged times, all-day flag and rrule → no time columns at all; * - non-recurring result → DTSTART/DTEND, DURATION and RRULE cleared; - * - recurring result → the *series* DTSTART moves by the same delta the user - * applied to the displayed occurrence ([seriesDtStartMillis] is the row's - * current DTSTART), DURATION replaces DTEND, RRULE is written. This keeps - * past occurrences intact when someone edits a later occurrence's time. + * - recurring result → the *series* DTSTART moves by the same **wall-clock** + * shift the user applied to the displayed occurrence and is re-resolved in the + * event's zone ([seriesDtStartMillis] is the row's current DTSTART), DURATION + * replaces DTEND, RRULE is written. This keeps past occurrences intact when + * someone edits a later occurrence's time, and keeps the anchor's time-of-day + * stable across a DST boundary or a zone change between the two. */ internal fun buildEventUpdateValues( original: EventForm, @@ -158,8 +186,23 @@ internal fun buildEventUpdateValues( put(CalendarContract.Events.RRULE, null) put(CalendarContract.Events.DURATION, null) } else { - val startDelta = newTimes.dtStartMillis - original.toWriteTimes(zone).dtStartMillis - put(CalendarContract.Events.DTSTART, seriesDtStartMillis + startDelta) + // Move the series anchor by the *wall-clock* shift the user applied to the + // displayed occurrence, then re-resolve it in the event's (possibly new) + // zone — never by a millisecond delta. An instant delta silently bakes in + // the offset that happened to apply on the edited occurrence's date, which + // is a different offset from the series anchor's whenever a DST boundary + // sits between them, or whenever the zone itself changed. Working in wall + // clock keeps "09:00" meaning 09:00 at both ends. + val seriesLocal = Instant.ofEpochMilli(seriesDtStartMillis) + .atZone(original.writeZone(zone)).toLocalDateTime() + val wallClockShift = Duration.between(original.anchorLocal(), updated.anchorLocal()) + val shifted = seriesLocal.plus(wallClockShift) + // An all-day series anchor must sit on a UTC midnight. A pure day move + // already lands there (both ends are midnights), but *switching* a + // recurring event to all-day shifts by a time-of-day too, so snap. + val newSeriesLocal = if (updated.isAllDay) shifted.toLocalDate().atStartOfDay() else shifted + val newSeriesStart = newSeriesLocal.atZone(updated.writeZone(zone)) + put(CalendarContract.Events.DTSTART, newSeriesStart.toInstant().toEpochMilli()) put(CalendarContract.Events.DTEND, null) put(CalendarContract.Events.RRULE, updated.rrule) put(CalendarContract.Events.DURATION, newTimes.toRfc2445Duration(updated.isAllDay)) diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/domain/TimeZoneCatalog.kt b/app/src/main/java/de/jeanlucmakiola/calendula/domain/TimeZoneCatalog.kt index c107099..d19e483 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/domain/TimeZoneCatalog.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/domain/TimeZoneCatalog.kt @@ -31,8 +31,33 @@ data class TimeZoneOption( /** The leading segment ("Europe/Berlin" -> "Europe"); empty for bare ids like "UTC". */ val region: String get() = id.substringBeforeLast('/', missingDelimiterValue = "") + + /** + * The four fields [filterTimeZones] matches on, normalized once here rather + * than per query. Normalizing is not cheap — NFD decomposition plus a combining- + * mark strip — and a search re-examines every option on every keystroke, so + * doing it at construction turns ~2400 normalizations per character into a few + * hundred plain `startsWith`/`contains` calls. + * + * Declared in the class body, so it stays out of `equals`/`hashCode`/`copy`: + * it is derived state, and two options with the same id are the same option. + */ + internal val searchKeys: TimeZoneSearchKeys = TimeZoneSearchKeys( + city = city.normalizeForSearch(), + id = id.normalizeForSearch(), + displayName = displayName.normalizeForSearch(), + shortName = shortName.normalizeForSearch(), + ) } +/** Pre-normalized match targets for one [TimeZoneOption]. */ +internal data class TimeZoneSearchKeys( + val city: String, + val id: String, + val displayName: String, + val shortName: String, +) + private fun optionFor( zone: ZoneId, locale: Locale, @@ -87,9 +112,13 @@ private fun resolveAbbreviation( private fun String.looksLikeOffset(): Boolean = this == "UTC" || startsWith("GMT") /** - * Every zone the JVM knows, resolved at [at]. This is ~600 entries, so build it - * once and filter the result rather than rebuilding per keystroke. [regionOf] - * (see [resolveAbbreviation]) supplies the zone's region for the abbreviation. + * Every zone the JVM knows, resolved at [at]. [regionOf] (see + * [resolveAbbreviation]) supplies the zone's region for the abbreviation. + * + * This is ~600 entries, each costing a localized display name plus up to two ICU + * short-name lookups, so it is **not** cheap enough for the main thread — build + * it off-thread once and filter the result with [filterTimeZones] rather than + * rebuilding per keystroke. * * Bare three-letter ids ("EST", "CST6CDT") and the legacy SystemV tree are * dropped: they're aliases the tz database keeps for compatibility, they'd @@ -154,18 +183,15 @@ fun filterTimeZones(options: List, query: String): List - val city = option.city.normalizeForSearch() - val id = option.id.normalizeForSearch() - val name = option.displayName.normalizeForSearch() - val abbrev = option.shortName.normalizeForSearch() + val keys = option.searchKeys val rank = when { - city.startsWith(needle) -> 0 - abbrev == needle -> 1 - name.startsWith(needle) -> 2 - abbrev.startsWith(needle) -> 3 - city.contains(needle) -> 4 - id.contains(needle) -> 5 - name.contains(needle) -> 6 + keys.city.startsWith(needle) -> 0 + keys.shortName == needle -> 1 + keys.displayName.startsWith(needle) -> 2 + keys.shortName.startsWith(needle) -> 3 + keys.city.contains(needle) -> 4 + keys.id.contains(needle) -> 5 + keys.displayName.contains(needle) -> 6 else -> return@mapNotNull null } rank to option @@ -174,6 +200,9 @@ fun filterTimeZones(options: List, query: String): List, query: String): List, today: LocalDate, + zone: TimeZone, dimPast: Boolean, now: Instant, onEventClick: (EventInstance) -> Unit, @@ -379,6 +383,7 @@ private fun AgendaList( AgendaEventRow( event = event, day = day.date, + zone = zone, position = positionOf(index, day.events.size), dimmed = dimPast && event.hasEnded(now), modifier = animateItemMotion(), @@ -458,6 +463,7 @@ private fun AgendaEmptyDayRow(onClick: () -> Unit) { private fun AgendaEventRow( event: EventInstance, day: LocalDate, + zone: TimeZone, position: Position, dimmed: Boolean, modifier: Modifier = Modifier, @@ -469,7 +475,7 @@ private fun AgendaEventRow( GroupedRow( modifier = if (dimmed) modifier.alpha(EventDimAlpha) else modifier, title = title, - summary = agendaTimeSummary(event, day), + summary = agendaTimeSummary(event, day, zone), position = position, minHeight = 64.dp, leading = { @@ -575,25 +581,34 @@ private fun agendaDayLabel(date: LocalDate, today: LocalDate): String { * An all-day multi-day event is simply "All day" on every day it covers. */ @Composable -private fun agendaTimeSummary(event: EventInstance, day: LocalDate): String { +private fun agendaTimeSummary(event: EventInstance, day: LocalDate, zone: TimeZone): String { val is24Hour = LocalUse24HourFormat.current val locale = currentLocale() val time = when (val label = agendaTimeLabel(event, day, zone)) { AgendaTimeLabel.AllDay -> stringResource(R.string.event_detail_all_day) - is AgendaTimeLabel.Starts -> - stringResource(R.string.agenda_span_starts, formatTime(label.start, is24Hour, locale)) - is AgendaTimeLabel.Ends -> - stringResource(R.string.agenda_span_ends, formatTime(label.end, is24Hour, locale)) - is AgendaTimeLabel.Range -> - "${formatTime(label.start, is24Hour, locale)} – ${formatTime(label.end, is24Hour, locale)}" + is AgendaTimeLabel.Starts -> stringResource( + R.string.agenda_span_starts, + formatTime(label.start, zone, is24Hour, locale), + ) + is AgendaTimeLabel.Ends -> stringResource( + R.string.agenda_span_ends, + formatTime(label.end, zone, is24Hour, locale), + ) + is AgendaTimeLabel.Range -> "${formatTime(label.start, zone, is24Hour, locale)} – " + + formatTime(label.end, zone, is24Hour, locale) } val location = event.location?.takeIf { it.isNotBlank() } return if (location != null) "$time · $location" else time } -private fun formatTime(instant: Instant, is24Hour: Boolean, locale: Locale): String { +private fun formatTime( + instant: Instant, + zone: TimeZone, + is24Hour: Boolean, + locale: Locale, +): String { val t = instant.toLocalDateTime(zone).time return formatTimeOfDay(t.hour, t.minute, is24Hour, locale) } 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 b88b1a7..8bd3be8 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 @@ -160,5 +160,12 @@ sealed interface AgendaUiState { val rangeEnd: LocalDate, /** Whether to show the top range bar — header + switcher (toggle, on by default). */ val showRangeBar: Boolean, + /** + * The zone [days] were grouped in. Carried in the state rather than + * re-read by the screen so labelling and grouping cannot disagree: an + * event's "Starts …/Ends …/All day" line is only correct relative to the + * same zone that decided which day it was filed under. + */ + val zone: TimeZone, ) : AgendaUiState } 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 924f6b8..d19271e 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 @@ -172,6 +172,7 @@ class AgendaViewModel @Inject constructor( rangeIsOverride = params.rangeIsOverride, rangeEnd = rangeEnd, showRangeBar = params.showRangeBar, + zone = zone, ) } } diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/CalendarTitle.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/CalendarTitle.kt index 3cb3422..8b87359 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/CalendarTitle.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/CalendarTitle.kt @@ -16,13 +16,19 @@ import java.util.Locale * sits directly above a grid that already says which year it is, and the year's * *absence* is itself the signal that you're in the current one — it appears the * moment you page out of it, which is when it starts carrying information. + * + * [forceYear] overrides that for titles whose [date] does not tell the whole + * story — a week view's title names only the month its *first* day falls in, so + * a week straddling New Year must still show the year even though [date] is in + * [currentYear]. */ fun formatCalendarTitle( date: java.time.LocalDate, locale: Locale, currentYear: Int, skeleton: String, + forceYear: Boolean = false, ): String { - val fields = if (date.year == currentYear) skeleton else skeleton + "y" + val fields = if (date.year == currentYear && !forceYear) skeleton else skeleton + "y" return localizedDateFormatter(locale, fields).format(date) } diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/TimeZonePicker.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/TimeZonePicker.kt index fc5d472..fe06016 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/TimeZonePicker.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/TimeZonePicker.kt @@ -21,6 +21,7 @@ import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.produceState import androidx.compose.runtime.remember import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue @@ -35,6 +36,7 @@ import androidx.compose.ui.unit.dp import de.jeanlucmakiola.calendula.R import de.jeanlucmakiola.calendula.domain.TimeZoneOption import de.jeanlucmakiola.calendula.domain.filterTimeZones +import de.jeanlucmakiola.calendula.domain.timeZoneOptionOf import de.jeanlucmakiola.calendula.domain.timeZoneOptions import de.jeanlucmakiola.calendula.domain.zoneDescriptor import de.jeanlucmakiola.floret.components.FullScreenPicker @@ -43,6 +45,8 @@ import de.jeanlucmakiola.floret.components.InlineTextField import de.jeanlucmakiola.floret.components.Position import de.jeanlucmakiola.floret.components.positionOf import de.jeanlucmakiola.floret.locale.currentLocale +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext /** * Full-screen zone picker: a query field over every zone the JVM knows, with the @@ -64,14 +68,28 @@ fun TimeZonePickerDialog( onDismiss: () -> Unit, ) { var query by rememberSaveable { mutableStateOf("") } - // ~600 zones, each resolving a localized name and a DST-aware offset: build - // once per open, then filter the result per keystroke. val locale = currentLocale() - val allZones = remember(locale) { timeZoneOptions(locale, regionOf = ::icuTimeZoneRegion) } + // ~600 zones, each resolving a localized name and up to two ICU short names: + // far too much to run inside composition, so build it on a worker and let the + // list fill in a frame later. Filtering the built catalogue is cheap (its + // search keys are pre-normalized), so that stays here. + val allZones by produceState(initialValue = emptyList(), locale) { + value = withContext(Dispatchers.Default) { + timeZoneOptions(locale, regionOf = ::icuTimeZoneRegion) + } + } val matches = remember(allZones, query) { filterTimeZones(allZones, query) } val recentZones = remember(allZones, recents) { recents.mapNotNull { id -> allZones.firstOrNull { it.id == id } } } + // Resolved on its own rather than looked up in the catalogue: it's one zone, + // so it costs nothing, and the device row can then render complete on the + // first frame instead of showing a bare id until the catalogue lands. + val deviceSummary = remember(deviceZoneId, locale) { + timeZoneOptionOf(deviceZoneId, locale, regionOf = ::icuTimeZoneRegion) + ?.let { zoneDescriptor(it) } + ?: deviceZoneId + } val searching = query.isNotBlank() fun choose(zoneId: String?) { @@ -97,7 +115,7 @@ fun TimeZonePickerDialog( item(key = "device") { GroupedRow( title = stringResource(R.string.event_edit_timezone_device), - summary = zoneSummary(deviceZoneId, allZones), + summary = deviceSummary, position = Position.Alone, selected = selected == null, leading = { Icon(Icons.Default.Public, contentDescription = null) }, @@ -130,7 +148,10 @@ fun TimeZonePickerDialog( } } - if (searching && matches.isEmpty()) { + // allZones.isNotEmpty() gates this: until the catalogue lands there is + // simply nothing to match yet, and claiming "no time zone matches" + // for that frame would be wrong. + if (searching && matches.isEmpty() && allZones.isNotEmpty()) { item(key = "empty") { Text( text = stringResource(R.string.event_edit_timezone_none, query), @@ -252,9 +273,3 @@ private fun ZoneRow( onClick = onClick, ) } - -/** "CET · GMT+01:00" for [zoneId], or the bare id if the catalogue lacks it. */ -private fun zoneSummary(zoneId: String, allZones: List): String = - allZones.firstOrNull { it.id == zoneId } - ?.let { zoneDescriptor(it) } - ?: zoneId diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/detail/EventDetailScreen.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/detail/EventDetailScreen.kt index 639f7fd..2deeb4e 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/detail/EventDetailScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/detail/EventDetailScreen.kt @@ -109,6 +109,7 @@ import de.jeanlucmakiola.calendula.ui.common.recurrenceText import de.jeanlucmakiola.calendula.ui.common.reminderLeadTimeLabel import kotlinx.coroutines.launch import kotlinx.datetime.TimeZone +import kotlin.time.toJavaInstant import java.time.ZoneId import java.time.format.DateTimeFormatter import java.time.format.FormatStyle @@ -463,8 +464,8 @@ private fun EventDetailContent(state: EventDetailUiState.Success, modifier: Modi // Same hierarchy as the When card above — the label carries the card, // the time sits small beneath it. The local time is the one the reader // acts on, so the original must stay quieter than it, not compete. - val foreignZone = remember(detail.eventTimezone, instance.isAllDay, locale) { - foreignTimeZone(detail.eventTimezone, instance.isAllDay, locale) + val foreignZone = remember(detail.eventTimezone, instance.isAllDay, instance.start, locale) { + foreignTimeZone(detail.eventTimezone, instance.isAllDay, locale, instance.start) } foreignZone?.let { zoneOption -> Spacer(Modifier.height(gap)) @@ -789,11 +790,26 @@ private fun reminderLeadText(reminder: Reminder): String = reminderLeadTimeLabel * pinned to a zone different from the device's — the cases where showing it * removes ambiguity. Null otherwise (all-day, device-zone, blank, or an id the * tz database doesn't know). + * + * Resolved *at [at]*, the event's own start, not at "now": the abbreviation and + * offset both move with DST, and the card prints them next to the event's time. + * Resolving at now would label a July event "CET · 10:00 AM" when read in + * January — an abbreviation that contradicts the time beside it. */ -private fun foreignTimeZone(tz: String?, isAllDay: Boolean, locale: Locale): TimeZoneOption? { +private fun foreignTimeZone( + tz: String?, + isAllDay: Boolean, + locale: Locale, + at: Instant, +): TimeZoneOption? { if (isAllDay || tz.isNullOrBlank()) return null if (tz == ZoneId.systemDefault().id) return null - return timeZoneOptionOf(tz, locale, regionOf = ::icuTimeZoneRegion) + return timeZoneOptionOf( + tz, + locale, + at = at.toJavaInstant(), + regionOf = ::icuTimeZoneRegion, + ) } /** Wrap http(s) URLs in [text] as tappable links tinted [linkColor]. */ diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/edit/EventEditScreen.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/edit/EventEditScreen.kt index 3f5d920..74ec60b 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/edit/EventEditScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/edit/EventEditScreen.kt @@ -47,7 +47,6 @@ import androidx.compose.material.icons.filled.Person import androidx.compose.material.icons.filled.PersonAdd import androidx.compose.material.icons.filled.Place import androidx.compose.material.icons.filled.Public -import androidx.compose.material.icons.filled.Public import androidx.compose.material.icons.filled.Repeat import androidx.compose.material.icons.filled.Schedule import androidx.compose.material.icons.filled.Tune @@ -164,8 +163,10 @@ import kotlinx.datetime.isoDayNumber import kotlinx.datetime.toJavaDayOfWeek import kotlinx.datetime.toJavaLocalDate import kotlinx.datetime.toKotlinDayOfWeek +import kotlinx.datetime.toInstant import kotlinx.datetime.toJavaLocalTime import kotlinx.datetime.toLocalDateTime +import kotlin.time.toJavaInstant import java.time.ZoneId import java.time.format.DateTimeFormatter import java.time.format.FormatStyle @@ -683,7 +684,13 @@ private fun EventEditContent( // whoever is reading it. Spell the local equivalent out rather than // leaving the user to do the offset arithmetic. Absent (null) unless // the event is pinned somewhere other than here. - form.timesIn(TimeZone.currentSystemDefault())?.let { (localStart, localEnd) -> + // Keyed rather than recomputed: this sits in the same Column as the + // title field, so it would otherwise re-parse the zone on every + // keystroke. + val localTimes = remember(form.timezone, form.start, form.end, form.isAllDay) { + form.timesIn(TimeZone.currentSystemDefault()) + } + localTimes?.let { (localStart, localEnd) -> Spacer(Modifier.height(2.dp)) Text( text = stringResource( @@ -741,8 +748,20 @@ private fun EventEditContent( verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth(), ) { - val pinned = remember(form.timezone, locale) { - form.timezone?.let { timeZoneOptionOf(it, locale, regionOf = ::icuTimeZoneRegion) } + // Resolved at the event's own start, not at "now": the + // abbreviation and offset shown ("CEST · GMT+02:00") must be + // the ones that apply when the event actually happens. + val pinned = remember(form.timezone, form.start, locale) { + form.timezone + ?.let { id -> runCatching { id to TimeZone.of(id) }.getOrNull() } + ?.let { (id, zone) -> + timeZoneOptionOf( + id, + locale, + at = form.start.toInstant(zone).toJavaInstant(), + regionOf = ::icuTimeZoneRegion, + ) + } } Column(modifier = Modifier.weight(1f)) { Text( diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/SettingsViewModel.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/SettingsViewModel.kt index 8b6c58e..de3bcbe 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/SettingsViewModel.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/SettingsViewModel.kt @@ -213,10 +213,22 @@ class SettingsViewModel @Inject constructor( * imperatively via [LauncherNameManager] and held here in its own flow — the * main settings combine is already at its arity limit and this isn't a * DataStore flow anyway. + * + * Seeded with the manifest default and corrected off-thread rather than read + * in the initializer: `getComponentEnabledSetting` is a binder round-trip to + * system_server, and this ViewModel is constructed on the main thread while + * Settings is opening. The row it feeds is well below the fold, so the + * one-frame correction is never visible. */ - private val _launcherName = MutableStateFlow(launcherNameManager.current()) + private val _launcherName = MutableStateFlow(LauncherName.CALENDULA) val launcherName: StateFlow = _launcherName.asStateFlow() + init { + viewModelScope.launch { + _launcherName.value = withContext(io) { launcherNameManager.current() } + } + } + // Emitted when a picked font file couldn't be read as a font; the screen // surfaces it and the previous selection stays put. private val _fontImportFailed = MutableSharedFlow(extraBufferCapacity = 1) @@ -484,11 +496,20 @@ class SettingsViewModel @Inject constructor( viewModelScope.launch { prefs.setTodayButtonInToolbar(enabled) } } - /** Switch the launcher label between "Calendula" and "Calendar" (issue #44). */ + /** + * Switch the launcher label between "Calendula" and "Calendar" (issue #44). + * The card highlights immediately, then settles on whatever the component + * state actually reports — the two `setComponentEnabledSetting` calls are + * binder round-trips, so they don't belong on the main thread either. + */ fun setLauncherName(name: LauncherName) { - launcherNameManager.set(name) - // Re-read so the UI reflects the actual component state, not an assumption. - _launcherName.value = launcherNameManager.current() + _launcherName.value = name + viewModelScope.launch { + _launcherName.value = withContext(io) { + launcherNameManager.set(name) + launcherNameManager.current() + } + } } fun setPastEventDisplay(mode: PastEventDisplay) { diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/week/WeekScreen.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/week/WeekScreen.kt index bd44535..88b9a14 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/week/WeekScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/week/WeekScreen.kt @@ -880,13 +880,21 @@ private fun minToHm(min: Int, is24Hour: Boolean, locale: java.util.Locale): Stri * month of [weekStart] means a week straddling a boundary keeps the outgoing * month until it is fully gone — a week is seven contiguous days, so the earlier * month has a day in it exactly while [weekStart] is still inside it. That also - * makes the title depend on nothing but [weekStart], so it cannot drift with the - * direction you paged in from. + * makes the *month* depend on nothing but [weekStart], so it cannot drift with + * the direction you paged in from. + * + * The *year* is decided from the whole week, not just its start: a week running + * Dec 28 – Jan 3 has four of its seven visible columns in the new year, so + * "December" with no year would be actively misleading while the reader is + * looking straight at January dates. */ -private fun formatWeekTitle(weekStart: LocalDate, locale: Locale, currentYear: Int): String = - formatCalendarTitle( +private fun formatWeekTitle(weekStart: LocalDate, locale: Locale, currentYear: Int): String { + val weekEnd = weekStart.plus(6, kotlinx.datetime.DateTimeUnit.DAY) + return formatCalendarTitle( date = java.time.LocalDate.of(weekStart.year, weekStart.month.ordinal + 1, 1), locale = locale, currentYear = currentYear, skeleton = "LLLL", + forceYear = weekEnd.year != weekStart.year, ) +} diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 2b8019e..bfb853d 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -110,7 +110,6 @@ Recent All time zones No time zone matches “%1$s” - Use device zone diff --git a/app/src/test/java/de/jeanlucmakiola/calendula/data/calendar/EventWriteMapperTest.kt b/app/src/test/java/de/jeanlucmakiola/calendula/data/calendar/EventWriteMapperTest.kt index b606a15..596d35b 100644 --- a/app/src/test/java/de/jeanlucmakiola/calendula/data/calendar/EventWriteMapperTest.kt +++ b/app/src/test/java/de/jeanlucmakiola/calendula/data/calendar/EventWriteMapperTest.kt @@ -123,8 +123,18 @@ class EventWriteMapperTest { private val seriesStart = 1_700_000_000_000L - private fun update(original: EventForm, updated: EventForm): Map = - buildEventUpdateValues(original, updated, seriesStart, berlin) + private fun update( + original: EventForm, + updated: EventForm, + series: Long = seriesStart, + ): Map = buildEventUpdateValues(original, updated, series, berlin) + + /** The instant [local] names in [zoneId], as the provider would store it. */ + private fun instantAt(local: String, zoneId: String): Long = + java.time.LocalDateTime.parse(local) + .atZone(java.time.ZoneId.of(zoneId)) + .toInstant() + .toEpochMilli() @Test fun `pristine form produces no values`() { @@ -219,6 +229,77 @@ class EventWriteMapperTest { assertThat(values[CalendarContract.Events.DURATION]).isEqualTo("P5400S") } + @Test + fun `pinning a recurring event to another zone keeps the series wall clock`() { + // The regression this guards: the series DTSTART used to move by a + // millisecond delta measured at the *edited occurrence*. Here the series + // anchor sits in January (Berlin CET, +1) and the edited occurrence in + // July (Berlin CEST, +2), so the July delta is an hour off for January — + // the whole series would have drifted to 10:00 Tokyo. + val series = instantAt("2026-01-07T09:00", "Europe/Berlin") + val original = form( + start = LocalDateTime(LocalDate(2026, 7, 15), LocalTime(9, 0)), + end = LocalDateTime(LocalDate(2026, 7, 15), LocalTime(10, 0)), + ).copy(rrule = "FREQ=WEEKLY") + + val values = update(original, original.copy(timezone = "Asia/Tokyo"), series) + + assertThat(values[CalendarContract.Events.EVENT_TIMEZONE]).isEqualTo("Asia/Tokyo") + // The anchor still reads 09:00 — now 09:00 in Tokyo, not 10:00. + assertThat(values[CalendarContract.Events.DTSTART]) + .isEqualTo(instantAt("2026-01-07T09:00", "Asia/Tokyo")) + } + + @Test + fun `a time edit moves the series anchor in wall clock across a DST boundary`() { + // Anchor in winter, edited occurrence in summer: pushing the occurrence + // one hour later must leave the anchor at 10:00 winter time, not at an + // instant that re-reads as 11:00 once the offset differs. + val series = instantAt("2026-01-07T09:00", "Europe/Berlin") + val original = form( + start = LocalDateTime(LocalDate(2026, 7, 15), LocalTime(9, 0)), + end = LocalDateTime(LocalDate(2026, 7, 15), LocalTime(10, 0)), + ).copy(rrule = "FREQ=WEEKLY") + val moved = original.copy( + start = LocalDateTime(LocalDate(2026, 7, 15), LocalTime(10, 0)), + end = LocalDateTime(LocalDate(2026, 7, 15), LocalTime(11, 0)), + ) + + assertThat(update(original, moved, series)[CalendarContract.Events.DTSTART]) + .isEqualTo(instantAt("2026-01-07T10:00", "Europe/Berlin")) + } + + @Test + fun `moving a recurring occurrence to another day shifts the anchor by whole days`() { + val series = instantAt("2026-01-07T09:00", "Europe/Berlin") + val original = form( + start = LocalDateTime(LocalDate(2026, 1, 7), LocalTime(9, 0)), + end = LocalDateTime(LocalDate(2026, 1, 7), LocalTime(10, 0)), + ).copy(rrule = "FREQ=WEEKLY") + val moved = original.copy( + start = LocalDateTime(LocalDate(2026, 1, 9), LocalTime(14, 30)), + end = LocalDateTime(LocalDate(2026, 1, 9), LocalTime(15, 30)), + ) + + assertThat(update(original, moved, series)[CalendarContract.Events.DTSTART]) + .isEqualTo(instantAt("2026-01-09T14:30", "Europe/Berlin")) + } + + @Test + fun `switching a recurring event to all-day anchors the series on a UTC midnight`() { + val series = instantAt("2026-01-07T09:00", "Europe/Berlin") + val original = form( + start = LocalDateTime(LocalDate(2026, 7, 15), LocalTime(9, 0)), + end = LocalDateTime(LocalDate(2026, 7, 15), LocalTime(10, 0)), + ).copy(rrule = "FREQ=WEEKLY") + + val values = update(original, original.copy(isAllDay = true), series) + + assertThat(values[CalendarContract.Events.EVENT_TIMEZONE]).isEqualTo("UTC") + val dtStart = values[CalendarContract.Events.DTSTART] as Long + assertThat(dtStart % (24L * 60 * 60 * 1000)).isEqualTo(0L) + } + @Test fun `adding a recurrence keeps the times and writes rule plus duration`() { val original = form() From a34f29bcf825136177399b9465af31be843972af Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Mon, 20 Jul 2026 14:47:37 +0200 Subject: [PATCH 33/78] fix(widget): address review of the agenda size scaling (#51) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Follow-up to 30fcbfa, fixing eight issues found in review: - Cap the agenda row list at 100. SizeMode.Exact asks Glance for one RemoteViews per host size where SizeMode.Single produced exactly one, roughly doubling the payload; with the range reaching AgendaRange.MAX_CUSTOM_DAYS (365) an uncapped list could push past the binder transaction limit and the host would show "Problem loading widget". A trailing day header stranded by the cut is dropped. - Loosen the height cap so it only catches genuinely squashed widgets. It previously required ~320dp of height for LARGE, so widening a widget without also making it unusually tall — the exact resize #51 reports — stayed REGULAR or COMPACT and the feature was near a no-op for it. Thresholds now work on height minus header chrome. - Lock the event stripe to the system font scale. It is a Dp beside sp text, so at large accessibility settings the text outgrew it and it under-ran the row it marks. - Route the day-header and placeholder padding through the metrics table so vertical rhythm holds at the larger tiers, and derive the text indent from the row constants instead of a hardcoded 19dp. - Share the bucketing as widget/WidgetScale.kt so MonthWidget (already SizeMode.Exact) can adopt one rule rather than growing a parallel copy. - Anchor the type ramp to Material 3 type-scale roles per CLAUDE.md, with the two off-scale values marked and justified inline. COMPACT is unchanged, so a default-sized widget still looks exactly as before. - Tie the "default size unchanged" test to the provider XML's declared 3-cell band rather than one measured 222dp point. - Hang the metrics off an ordinal-indexed table so lookup allocates nothing per recomposition. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../calendula/widget/WidgetScale.kt | 70 +++++ .../calendula/widget/agenda/AgendaScale.kt | 247 ++++++++++-------- .../calendula/widget/agenda/AgendaWidget.kt | 53 +++- .../calendula/widget/WidgetScaleTest.kt | 89 +++++++ .../widget/agenda/AgendaScaleTest.kt | 136 +++++----- 5 files changed, 412 insertions(+), 183 deletions(-) create mode 100644 app/src/main/java/de/jeanlucmakiola/calendula/widget/WidgetScale.kt create mode 100644 app/src/test/java/de/jeanlucmakiola/calendula/widget/WidgetScaleTest.kt diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/widget/WidgetScale.kt b/app/src/main/java/de/jeanlucmakiola/calendula/widget/WidgetScale.kt new file mode 100644 index 0000000..9b19179 --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/calendula/widget/WidgetScale.kt @@ -0,0 +1,70 @@ +package de.jeanlucmakiola.calendula.widget + +import androidx.compose.ui.unit.DpSize +import androidx.compose.ui.unit.dp + +/** + * Size tiers a widget scales its typography and metrics across (#51). + * + * Shared by every Glance widget so the bucketing rule can't drift between them: + * each widget keeps its own metrics table, but they all agree on *when* a widget + * counts as compact, regular, large or extra-large. Both widgets already declare + * [androidx.glance.appwidget.SizeMode.Exact], so the composition sees the live + * size via `LocalSize.current` and passes it to [scaleFor]. + * + * Kept in a pure, Glance-free file (only `compose.ui.unit`) so the bucketing is + * covered by plain JVM tests. + */ +internal enum class WidgetScale { COMPACT, REGULAR, LARGE, XLARGE } + +/** + * Chrome a widget spends before its first content row: outer vertical padding + * plus a header row and its spacer. Subtracted from the raw height so the height + * thresholds below talk about *usable* space rather than gross widget height. + */ +private val CHROME_HEIGHT = 60.dp + +/** + * Buckets a live widget size into a [WidgetScale] by **width**. + * + * Width is the right axis: it governs how much of a title fits on a row, so it's + * what should drive type size. Height only decides how many rows are visible — a + * tall, narrow widget wants *more events*, not bigger text — so it never raises + * the tier. It does act as a **cap**, though: a genuinely squashed widget is + * stepped back down so it can't keep oversized type in a sliver of space. + * + * The width thresholds are spread across the range a phone can actually produce + * (~180dp up to roughly the screen width) rather than over a theoretical range, + * so the tiers are reachable in practice. Calibrated on-device (Pixel / Nova): a + * compact 222dp-wide widget stays COMPACT (the app's baseline, unchanged) and a + * full-width 378dp one reaches LARGE. XLARGE is reserved for genuinely wide + * surfaces — tablets, foldables, landscape — where the extra size reads well. + * + * The height cap is deliberately generous: it exists to catch a widget squashed + * to one or two rows, **not** to gate ordinary placements. A full-width widget at + * the usual three cells tall (~270dp) must still reach the tier its width earned + * — that is exactly the resize #51 reports, and an aggressive cap would make the + * whole feature a no-op for it. + */ +internal fun scaleFor(size: DpSize): WidgetScale { + val byWidth = when { + size.width < 260.dp -> WidgetScale.COMPACT + size.width < 330.dp -> WidgetScale.REGULAR + size.width < 420.dp -> WidgetScale.LARGE + else -> WidgetScale.XLARGE + } + // Height can only ever pull the tier *down*, never push it up: a squashed + // widget would otherwise keep the big type its width earned and look absurd + // in the little space left. Keeping this a cap (rather than a second scaling + // axis) is what preserves "tall and narrow shows more events, not bigger + // text". Thresholds are usable height — roughly one, two and three rows of + // breathing room once the header is paid for. + val usable = size.height - CHROME_HEIGHT + val heightCap = when { + usable < 70.dp -> WidgetScale.COMPACT + usable < 130.dp -> WidgetScale.REGULAR + usable < 200.dp -> WidgetScale.LARGE + else -> WidgetScale.XLARGE + } + return minOf(byWidth, heightCap) +} diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/widget/agenda/AgendaScale.kt b/app/src/main/java/de/jeanlucmakiola/calendula/widget/agenda/AgendaScale.kt index 5e1880d..1e55303 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/widget/agenda/AgendaScale.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/widget/agenda/AgendaScale.kt @@ -1,129 +1,150 @@ package de.jeanlucmakiola.calendula.widget.agenda import androidx.compose.ui.unit.Dp -import androidx.compose.ui.unit.DpSize import androidx.compose.ui.unit.TextUnit import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp +import de.jeanlucmakiola.calendula.widget.WidgetScale /** - * Size tiers the agenda widget scales its typography and row metrics across (#51). - * - * The widget uses [androidx.glance.appwidget.SizeMode.Exact], so the composition - * sees the widget's live size via `LocalSize.current`; [scaleFor] buckets that - * into one of these tiers and [metricsFor] returns the sizes to draw with. Kept - * in a pure, Glance-free file (only `compose.ui.unit`) so the bucketing and the - * "default size is unchanged" invariant are covered by plain JVM tests. + * Horizontal layout constants for an agenda event row. These don't scale with the + * tier — a wider stripe or gap would eat title width, which is the thing the row + * is short of — but [TEXT_INDENT] is *derived* from them so the day header and + * the "nothing left today" line can never drift out of alignment with the event + * title column the way a hardcoded 19dp could. */ -internal enum class AgendaScale { COMPACT, REGULAR, LARGE, XLARGE } +internal val ROW_H_PAD = 4.dp +internal val STRIPE_WIDTH = 5.dp +internal val STRIPE_GAP = 10.dp + +/** Left indent that lines non-event text up with the event title column. */ +internal val TEXT_INDENT = ROW_H_PAD + STRIPE_WIDTH + STRIPE_GAP /** - * Every size the agenda widget varies by tier. Values that don't need to grow - * (horizontal paddings, the 5.dp stripe width, corner radii) stay inline literals - * in the widget rather than routing through here. + * The width band a *default* agenda placement can land in, per + * `app/src/main/res/xml/appwidget_info_agenda.xml` (`android:minWidth="180dp"`, + * `android:targetCellWidth="3"`). Measured at 222dp on a Pixel running Nova, but + * launcher cell grids vary, so the whole band — not one measured point — has to + * stay [WidgetScale.COMPACT] for the "default size is unchanged" promise of #51 + * to hold. A test pins that. + * + * If the provider's `targetCellWidth` ever changes, this band and the first + * width threshold in [de.jeanlucmakiola.calendula.widget.scaleFor] must be + * revisited together. + */ +internal val AGENDA_DEFAULT_WIDTH_BAND = 180.dp..255.dp + +/** + * Every size the agenda widget varies by tier. Values that genuinely shouldn't + * grow (the horizontal row constants above, corner radii) stay constants rather + * than routing through here. */ internal data class AgendaMetrics( - val title: TextUnit, // header "Upcoming" - val dayHeader: TextUnit, // day label ("Today · …") - val eventTitle: TextUnit, // event title line - val eventTime: TextUnit, // time/location line - val placeholder: TextUnit, // "no more events today" (#35) - val message: TextUnit, // empty/permission centred message - val stripeH: Dp, // coloured event stripe height - val iconImage: Dp, // header action icon glyph - val iconBox: Dp, // header action touch target - val rowVPad: Dp, // event row vertical padding + val title: TextUnit, // header "Upcoming" + val dayHeader: TextUnit, // day label ("Today · …") + val eventTitle: TextUnit, // event title line + val eventTime: TextUnit, // time/location line + val placeholder: TextUnit, // "no more events today" (#35) + val message: TextUnit, // empty/permission centred message + val stripeH: Dp, // coloured event stripe height + val iconImage: Dp, // header action icon glyph + val iconBox: Dp, // header action touch target + val rowVPad: Dp, // event row vertical padding + val dayHeaderTopPad: Dp, // space above a day header +) { + /** + * Resolves the stripe height against the user's system font scale. + * + * The stripe is a [Dp] but the two text lines it sits beside are `sp`, so + * they scale with the accessibility font setting and the stripe does not — + * at "Largest" the text outgrows the stripe and it visibly under-runs the row + * it is supposed to mark. Multiplying by the same factor keeps them locked, + * and at the default scale of 1.0 reproduces the tier's value exactly. + */ + fun scaledForFont(fontScale: Float): AgendaMetrics = + if (fontScale == 1f) this else copy(stripeH = stripeH * fontScale) +} + +/* + * Type sizes are anchored to the Material 3 type scale (see the `material-3` + * skill's typography reference) rather than invented: 16sp is Title Medium, 14sp + * Body Medium / Title Small, 12sp Body Small, 16sp Body Large, 22sp Title Large. + * + * Two documented deviations: + * + * ‡ COMPACT's 13sp day header is off-scale. It is held there deliberately — + * COMPACT reproduces the widget's original constants verbatim so a + * default-sized widget looks exactly as it did (#51), and snapping it to + * Title Small (14sp) would break that promise for a 1sp gain. + * + * † Above Title Medium the M3 scale jumps 16 → 22 → 24 with nothing in between, + * which is far too coarse for four widget tiers. Where a role would force a + * ≥1.4x step between adjacent tiers we hold an interpolated value instead and + * mark it. The endpoints stay on real roles. + */ + +private val COMPACT_METRICS = AgendaMetrics( + title = 16.sp, // M3 Title Medium + dayHeader = 13.sp, // ‡ off-scale, holds the #51 baseline + eventTitle = 14.sp, // M3 Body Medium + eventTime = 12.sp, // M3 Body Small + placeholder = 14.sp, // M3 Body Medium + message = 14.sp, // M3 Body Medium + stripeH = 36.dp, + iconImage = 22.dp, + iconBox = 40.dp, + rowVPad = 4.dp, + dayHeaderTopPad = 10.dp, ) -/** - * [AgendaMetrics] per tier. [AgendaScale.COMPACT] reproduces the widget's original - * hardcoded constants **verbatim** — so at the default/small size nothing changes; - * a JVM test pins this against the baseline. Larger tiers scale up by roughly - * 1.12× / 1.28× / 1.45× (starting points to refine on-device). - */ -internal fun metricsFor(scale: AgendaScale): AgendaMetrics = when (scale) { - AgendaScale.COMPACT -> AgendaMetrics( - title = 16.sp, - dayHeader = 13.sp, - eventTitle = 14.sp, - eventTime = 12.sp, - placeholder = 14.sp, - message = 14.sp, - stripeH = 36.dp, - iconImage = 22.dp, - iconBox = 40.dp, - rowVPad = 4.dp, - ) - AgendaScale.REGULAR -> AgendaMetrics( - title = 18.sp, - dayHeader = 14.sp, - eventTitle = 15.sp, - eventTime = 13.sp, - placeholder = 15.sp, - message = 15.sp, - stripeH = 40.dp, - iconImage = 24.dp, - iconBox = 44.dp, - rowVPad = 5.dp, - ) - AgendaScale.LARGE -> AgendaMetrics( - title = 20.sp, - dayHeader = 16.sp, - eventTitle = 17.sp, - eventTime = 14.sp, - placeholder = 16.sp, - message = 16.sp, - stripeH = 46.dp, - iconImage = 26.dp, - iconBox = 48.dp, - rowVPad = 6.dp, - ) - AgendaScale.XLARGE -> AgendaMetrics( - title = 22.sp, - dayHeader = 18.sp, - eventTitle = 19.sp, - eventTime = 15.sp, - placeholder = 18.sp, - message = 18.sp, - stripeH = 52.dp, - iconImage = 28.dp, - iconBox = 52.dp, - rowVPad = 8.dp, - ) -} +private val REGULAR_METRICS = AgendaMetrics( + title = 18.sp, // † + dayHeader = 14.sp, // M3 Title Small + eventTitle = 16.sp, // M3 Body Large + eventTime = 14.sp, // M3 Body Medium + placeholder = 16.sp, // M3 Body Large + message = 16.sp, // M3 Body Large + stripeH = 40.dp, + iconImage = 24.dp, + iconBox = 44.dp, + rowVPad = 5.dp, + dayHeaderTopPad = 11.dp, +) -/** - * Buckets a live widget size into an [AgendaScale] by **width**. - * - * Width is the right axis: it governs how much of a title fits on a row, so it's - * what should drive type size. Height only decides how many rows are visible — a - * tall, narrow widget wants *more events*, not bigger text — so it never raises - * the tier. It does act as a **cap**, though: a very short widget is stepped back - * down so it can't keep oversized type in a sliver of space. - * - * The thresholds are spread across the width range a phone can actually produce - * (~180dp up to roughly the screen width) rather than over a theoretical range, so - * the tiers are reachable in practice. Calibrated on-device (Pixel / Nova): a - * compact 222dp-wide widget stays COMPACT (the app's baseline, unchanged) and a - * large 378dp-wide one reaches LARGE. XLARGE is reserved for genuinely wide - * surfaces — tablets, foldables, landscape — where the extra size reads well. - */ -internal fun scaleFor(size: DpSize): AgendaScale { - val byWidth = when { - size.width < 260.dp -> AgendaScale.COMPACT - size.width < 330.dp -> AgendaScale.REGULAR - size.width < 420.dp -> AgendaScale.LARGE - else -> AgendaScale.XLARGE - } - // Height can only ever pull the tier *down*, never push it up: a squashed - // widget would otherwise keep the big type its width earned and look absurd - // in the little space left. Keeping this a cap (rather than a second scaling - // axis) is what preserves "tall and narrow shows more events, not bigger text". - val heightCap = when { - size.height < 220.dp -> AgendaScale.COMPACT - size.height < 320.dp -> AgendaScale.REGULAR - size.height < 420.dp -> AgendaScale.LARGE - else -> AgendaScale.XLARGE - } - return minOf(byWidth, heightCap) -} +private val LARGE_METRICS = AgendaMetrics( + title = 20.sp, // † + dayHeader = 16.sp, // M3 Title Medium + eventTitle = 18.sp, // † + eventTime = 14.sp, // M3 Body Medium — the secondary line steps more slowly + placeholder = 18.sp, // † on purpose, so the title keeps its + message = 18.sp, // † lead and the hierarchy survives. + stripeH = 46.dp, + iconImage = 26.dp, + iconBox = 48.dp, + rowVPad = 6.dp, + dayHeaderTopPad = 12.dp, +) + +private val XLARGE_METRICS = AgendaMetrics( + title = 22.sp, // M3 Title Large + dayHeader = 18.sp, // † + eventTitle = 20.sp, // † + eventTime = 16.sp, // M3 Body Large + placeholder = 20.sp, // † + message = 20.sp, // † + stripeH = 52.dp, + iconImage = 28.dp, + iconBox = 52.dp, + rowVPad = 8.dp, + dayHeaderTopPad = 14.dp, +) + +/** Indexed by [WidgetScale.ordinal] so lookup allocates nothing per recomposition. */ +private val AGENDA_METRICS = listOf( + COMPACT_METRICS, + REGULAR_METRICS, + LARGE_METRICS, + XLARGE_METRICS, +) + +internal fun metricsFor(scale: WidgetScale): AgendaMetrics = AGENDA_METRICS[scale.ordinal] diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/widget/agenda/AgendaWidget.kt b/app/src/main/java/de/jeanlucmakiola/calendula/widget/agenda/AgendaWidget.kt index c2fe4de..7a7f7d9 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/widget/agenda/AgendaWidget.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/widget/agenda/AgendaWidget.kt @@ -62,6 +62,7 @@ import de.jeanlucmakiola.floret.locale.localizedDateFormatter import de.jeanlucmakiola.calendula.ui.common.eventFill import de.jeanlucmakiola.calendula.widget.AgendaWidgetData import de.jeanlucmakiola.calendula.widget.CalendulaGlanceTheme +import de.jeanlucmakiola.calendula.widget.scaleFor import de.jeanlucmakiola.calendula.widget.loadAgendaWidgetData import de.jeanlucmakiola.calendula.widget.systemZone import de.jeanlucmakiola.calendula.widget.today @@ -108,11 +109,17 @@ class AgendaWidget : GlanceAppWidget() { override val stateDefinition = PreferencesGlanceStateDefinition - // Exact (not Responsive) so a single copy of the day/event list is laid out at - // the widget's live size — Responsive would replicate the whole LazyColumn per - // declared tier. The composition reads LocalSize.current and scales type/rows + // Exact so the composition sees the widget's live size and can scale type/rows // from it ([scaleFor]/[metricsFor]); at the default size that resolves to - // COMPACT, i.e. the layout is unchanged (#51). + // COMPACT, i.e. the layout is unchanged (#51). MonthWidget already does the + // same. + // + // Note Exact still asks Glance for one RemoteViews per host size (typically + // portrait + landscape) where the old SizeMode.Single produced exactly one — + // so the serialized payload roughly doubles. That is why the row list below is + // capped: an uncapped agenda (the range goes up to AgendaRange.MAX_CUSTOM_DAYS + // = 365) could otherwise push the RemoteViews past the binder transaction + // limit and the host would just show "Problem loading widget". override val sizeMode = SizeMode.Exact override suspend fun provideGlance(context: Context, id: GlanceId) { @@ -134,6 +141,15 @@ class RefreshAgendaAction : ActionCallback { } } +/** + * Upper bound on rows handed to the [LazyColumn], so the serialized RemoteViews + * stays well inside the binder transaction limit regardless of range and calendar + * size (see the [SizeMode.Exact] note above). Far more than fits on screen — a + * user scrolling a home-screen widget past a hundred rows is not a case worth + * risking a failed update for. + */ +private const val MAX_AGENDA_ROWS = 100 + /** 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 @@ -146,7 +162,10 @@ private sealed interface AgendaRow { private fun AgendaWidgetBody(data: AgendaWidgetData, dark: Boolean) { // Type and row metrics scale with the widget's live size (SizeMode.Exact); a // short/compact widget resolves to COMPACT, leaving the layout unchanged (#51). - val metrics = metricsFor(scaleFor(LocalSize.current)) + // The stripe is then re-resolved against the system font scale so it tracks the + // sp-sized text beside it instead of drifting at large accessibility settings. + val fontScale = androidx.glance.LocalContext.current.resources.configuration.fontScale + val metrics = metricsFor(scaleFor(LocalSize.current)).scaledForFont(fontScale) Column( modifier = GlanceModifier .fillMaxSize() @@ -202,6 +221,10 @@ private fun AgendaWidgetBody(data: AgendaWidgetData, dark: Boolean) { } } } + // Bound the payload, then drop a day header the cut left + // stranded with nothing under it. + .take(MAX_AGENDA_ROWS) + .dropLastWhile { it is AgendaRow.Header } LazyColumn(modifier = GlanceModifier.fillMaxSize()) { items(rows.size) { index -> when (val row = rows[index]) { @@ -300,7 +323,12 @@ private fun DayHeaderRow(date: LocalDate, today: LocalDate, metrics: AgendaMetri ), modifier = GlanceModifier .fillMaxWidth() - .padding(start = 8.dp, end = 8.dp, top = 10.dp, bottom = 4.dp) + .padding( + start = 8.dp, + end = 8.dp, + top = metrics.dayHeaderTopPad, + bottom = metrics.rowVPad, + ) .clickable( actionStartActivity( MainActivity.openDateIntent(context, date, CalendarView.Agenda), @@ -322,7 +350,12 @@ private fun PlaceholderRow(date: LocalDate, metrics: AgendaMetrics) { style = TextStyle(color = GlanceTheme.colors.onSurfaceVariant, fontSize = metrics.placeholder), modifier = GlanceModifier .fillMaxWidth() - .padding(start = 19.dp, end = 8.dp, top = 2.dp, bottom = 6.dp) + .padding( + start = TEXT_INDENT, + end = 8.dp, + top = 2.dp, + bottom = metrics.rowVPad + 2.dp, + ) .clickable( actionStartActivity( MainActivity.openDateIntent(context, date, CalendarView.Agenda), @@ -352,7 +385,7 @@ private fun EventRow( Row( modifier = GlanceModifier .fillMaxWidth() - .padding(horizontal = 4.dp, vertical = metrics.rowVPad) + .padding(horizontal = ROW_H_PAD, vertical = metrics.rowVPad) .clickable( actionStartActivity( MainActivity.openEventIntent( @@ -368,12 +401,12 @@ private fun EventRow( ) { Box( modifier = GlanceModifier - .width(5.dp) + .width(STRIPE_WIDTH) .height(metrics.stripeH) .cornerRadius(3.dp) .background(stripeColor), ) {} - Spacer(GlanceModifier.width(10.dp)) + Spacer(GlanceModifier.width(STRIPE_GAP)) Column(modifier = GlanceModifier.defaultWeight()) { Text( text = title, diff --git a/app/src/test/java/de/jeanlucmakiola/calendula/widget/WidgetScaleTest.kt b/app/src/test/java/de/jeanlucmakiola/calendula/widget/WidgetScaleTest.kt new file mode 100644 index 0000000..287400c --- /dev/null +++ b/app/src/test/java/de/jeanlucmakiola/calendula/widget/WidgetScaleTest.kt @@ -0,0 +1,89 @@ +package de.jeanlucmakiola.calendula.widget + +import androidx.compose.ui.unit.DpSize +import androidx.compose.ui.unit.dp +import com.google.common.truth.Truth.assertThat +import org.junit.jupiter.api.Test + +class WidgetScaleTest { + + @Test + fun `the on-device calibration points map to their tiers`() { + // The two sizes measured on-device: the compact widget stays COMPACT (the + // baseline, unchanged), the full-width one steps up to LARGE — not XLARGE, + // which read as too big on a phone (#51). + assertThat(scaleFor(DpSize(222.dp, 270.dp))).isEqualTo(WidgetScale.COMPACT) + assertThat(scaleFor(DpSize(378.dp, 672.dp))).isEqualTo(WidgetScale.LARGE) + } + + @Test + fun `a full-width widget at ordinary height still scales up`() { + // The regression the height cap used to cause: widening the widget without + // also making it unusually tall is *the* resize #51 reports, and it must + // reach the tier its width earned. Three cells tall is about 270dp. + assertThat(scaleFor(DpSize(378.dp, 270.dp))).isEqualTo(WidgetScale.LARGE) + assertThat(scaleFor(DpSize(300.dp, 270.dp))).isEqualTo(WidgetScale.REGULAR) + assertThat(scaleFor(DpSize(460.dp, 300.dp))).isEqualTo(WidgetScale.XLARGE) + } + + @Test + fun `width buckets into the four tiers`() { + // Tall enough that the height cap never binds, isolating the width rule. + val h = 500.dp + assertThat(scaleFor(DpSize(180.dp, h))).isEqualTo(WidgetScale.COMPACT) + assertThat(scaleFor(DpSize(259.dp, h))).isEqualTo(WidgetScale.COMPACT) + assertThat(scaleFor(DpSize(260.dp, h))).isEqualTo(WidgetScale.REGULAR) + assertThat(scaleFor(DpSize(329.dp, h))).isEqualTo(WidgetScale.REGULAR) + assertThat(scaleFor(DpSize(330.dp, h))).isEqualTo(WidgetScale.LARGE) + assertThat(scaleFor(DpSize(419.dp, h))).isEqualTo(WidgetScale.LARGE) + assertThat(scaleFor(DpSize(420.dp, h))).isEqualTo(WidgetScale.XLARGE) + assertThat(scaleFor(DpSize(900.dp, h))).isEqualTo(WidgetScale.XLARGE) + } + + @Test + fun `extra height never raises the tier`() { + // Height decides how many rows are visible, not how big they are: a tall, + // narrow widget wants more events, not bigger text. + assertThat(scaleFor(DpSize(222.dp, 200.dp))).isEqualTo(WidgetScale.COMPACT) + assertThat(scaleFor(DpSize(222.dp, 900.dp))).isEqualTo(WidgetScale.COMPACT) + assertThat(scaleFor(DpSize(300.dp, 900.dp))).isEqualTo(WidgetScale.REGULAR) + } + + @Test + fun `only a genuinely squashed widget is stepped back down`() { + // Same (wide) width, shrinking height. The cap exists to stop oversized type + // surviving in a one- or two-row sliver — it must not fire at normal heights. + // Heights are gross; the cap works on height minus 60dp of chrome, so the + // LARGE floor is 190dp (130dp usable) and the REGULAR floor 130dp (70dp). + val wide = 378.dp + assertThat(scaleFor(DpSize(wide, 400.dp))).isEqualTo(WidgetScale.LARGE) + assertThat(scaleFor(DpSize(wide, 260.dp))).isEqualTo(WidgetScale.LARGE) + assertThat(scaleFor(DpSize(wide, 190.dp))).isEqualTo(WidgetScale.LARGE) + assertThat(scaleFor(DpSize(wide, 189.dp))).isEqualTo(WidgetScale.REGULAR) + assertThat(scaleFor(DpSize(wide, 130.dp))).isEqualTo(WidgetScale.REGULAR) + assertThat(scaleFor(DpSize(wide, 129.dp))).isEqualTo(WidgetScale.COMPACT) + // The provider's declared floor (minResizeWidth/Height = 110dp) is COMPACT. + assertThat(scaleFor(DpSize(110.dp, 110.dp))).isEqualTo(WidgetScale.COMPACT) + } + + @Test + fun `the tier is monotonic in both axes`() { + // Growing a widget must never make its type smaller. Sweeps the whole + // plausible range rather than spot-checking, so a future threshold edit + // can't accidentally invert a step. + val widths = (110..900 step 7).map { it.dp } + val heights = (110..900 step 7).map { it.dp } + widths.forEach { w -> + heights.zipWithNext { shorter, taller -> + assertThat(scaleFor(DpSize(w, taller))) + .isAtLeast(scaleFor(DpSize(w, shorter))) + } + } + heights.forEach { h -> + widths.zipWithNext { narrower, wider -> + assertThat(scaleFor(DpSize(wider, h))) + .isAtLeast(scaleFor(DpSize(narrower, h))) + } + } + } +} diff --git a/app/src/test/java/de/jeanlucmakiola/calendula/widget/agenda/AgendaScaleTest.kt b/app/src/test/java/de/jeanlucmakiola/calendula/widget/agenda/AgendaScaleTest.kt index 6ce534e..edc84c4 100644 --- a/app/src/test/java/de/jeanlucmakiola/calendula/widget/agenda/AgendaScaleTest.kt +++ b/app/src/test/java/de/jeanlucmakiola/calendula/widget/agenda/AgendaScaleTest.kt @@ -4,70 +4,32 @@ import androidx.compose.ui.unit.DpSize import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import com.google.common.truth.Truth.assertThat +import de.jeanlucmakiola.calendula.widget.WidgetScale +import de.jeanlucmakiola.calendula.widget.scaleFor import org.junit.jupiter.api.Test class AgendaScaleTest { - // --- scaleFor: bucketing ------------------------------------------------- + // --- the "default size is unchanged" regression guard --------------------- @Test - fun `the on-device calibration points map to their tiers`() { - // The two sizes measured on-device: the compact widget stays COMPACT (the - // baseline, unchanged), the large one steps up to LARGE — not XLARGE, - // which read as too big on a phone (#51). - assertThat(scaleFor(DpSize(222.dp, 270.dp))).isEqualTo(AgendaScale.COMPACT) - assertThat(scaleFor(DpSize(378.dp, 672.dp))).isEqualTo(AgendaScale.LARGE) + fun `every default placement width stays COMPACT`() { + // Ties the guarantee to the provider XML's declared 3-cell default rather + // than to one measured launcher: the whole band a default placement can + // land in must bucket to COMPACT, or a freshly placed widget silently + // changes appearance (#51). See AGENDA_DEFAULT_WIDTH_BAND. + val band = AGENDA_DEFAULT_WIDTH_BAND + var w = band.start + while (w <= band.endInclusive) { + assertThat(scaleFor(DpSize(w, 270.dp))).isEqualTo(WidgetScale.COMPACT) + w += 1.dp + } } - @Test - fun `width buckets into the four tiers`() { - // Tall enough that the height cap never binds, isolating the width rule. - val h = 500.dp - assertThat(scaleFor(DpSize(180.dp, h))).isEqualTo(AgendaScale.COMPACT) - assertThat(scaleFor(DpSize(259.dp, h))).isEqualTo(AgendaScale.COMPACT) - assertThat(scaleFor(DpSize(260.dp, h))).isEqualTo(AgendaScale.REGULAR) - assertThat(scaleFor(DpSize(329.dp, h))).isEqualTo(AgendaScale.REGULAR) - assertThat(scaleFor(DpSize(330.dp, h))).isEqualTo(AgendaScale.LARGE) - assertThat(scaleFor(DpSize(419.dp, h))).isEqualTo(AgendaScale.LARGE) - assertThat(scaleFor(DpSize(420.dp, h))).isEqualTo(AgendaScale.XLARGE) - assertThat(scaleFor(DpSize(900.dp, h))).isEqualTo(AgendaScale.XLARGE) - } - - @Test - fun `extra height never raises the tier`() { - // Height decides how many rows are visible, not how big they are: a tall, - // narrow widget wants more events, not bigger text. - val short = scaleFor(DpSize(222.dp, 200.dp)) - val tall = scaleFor(DpSize(222.dp, 900.dp)) - assertThat(short).isEqualTo(AgendaScale.COMPACT) - assertThat(tall).isEqualTo(AgendaScale.COMPACT) - } - - @Test - fun `a squashed widget is stepped back down`() { - // Same (wide) width, shrinking height: the tier must walk down rather than - // keep big type in a sliver of space. - val wide = 378.dp - assertThat(scaleFor(DpSize(wide, 672.dp))).isEqualTo(AgendaScale.LARGE) - assertThat(scaleFor(DpSize(wide, 400.dp))).isEqualTo(AgendaScale.LARGE) - assertThat(scaleFor(DpSize(wide, 300.dp))).isEqualTo(AgendaScale.REGULAR) - assertThat(scaleFor(DpSize(wide, 200.dp))).isEqualTo(AgendaScale.COMPACT) - } - - @Test - fun `the height cap never exceeds what the width earned`() { - // A tall but narrow widget stays at its width's tier — the cap only ever - // lowers, so a huge height can't promote a 222dp-wide widget. - assertThat(scaleFor(DpSize(222.dp, 900.dp))).isEqualTo(AgendaScale.COMPACT) - assertThat(scaleFor(DpSize(300.dp, 900.dp))).isEqualTo(AgendaScale.REGULAR) - } - - // --- metricsFor: the "default unchanged" regression guard ----------------- - @Test fun `COMPACT metrics equal the widget's original constants`() { // If this fails, a default-sized agenda widget no longer looks as it did. - val m = metricsFor(AgendaScale.COMPACT) + val m = metricsFor(WidgetScale.COMPACT) assertThat(m.title).isEqualTo(16.sp) assertThat(m.dayHeader).isEqualTo(13.sp) assertThat(m.eventTitle).isEqualTo(14.sp) @@ -78,16 +40,22 @@ class AgendaScaleTest { assertThat(m.iconImage).isEqualTo(22.dp) assertThat(m.iconBox).isEqualTo(40.dp) assertThat(m.rowVPad).isEqualTo(4.dp) + assertThat(m.dayHeaderTopPad).isEqualTo(10.dp) } @Test - fun `type sizes are non-decreasing across the tiers`() { - val tiers = listOf( - AgendaScale.COMPACT, - AgendaScale.REGULAR, - AgendaScale.LARGE, - AgendaScale.XLARGE, - ).map(::metricsFor) + fun `the derived text indent matches the original hardcoded inset`() { + // TEXT_INDENT replaced a hardcoded 19dp; it must still resolve to 19dp or + // day headers and the placeholder line stop aligning with event titles. + assertThat(TEXT_INDENT).isEqualTo(19.dp) + assertThat(TEXT_INDENT).isEqualTo(ROW_H_PAD + STRIPE_WIDTH + STRIPE_GAP) + } + + // --- the ramp ------------------------------------------------------------ + + @Test + fun `sizes are non-decreasing across the tiers`() { + val tiers = WidgetScale.entries.map(::metricsFor) tiers.zipWithNext { small, big -> assertThat(big.title.value).isAtLeast(small.title.value) @@ -100,6 +68,54 @@ class AgendaScaleTest { assertThat(big.iconImage.value).isAtLeast(small.iconImage.value) assertThat(big.iconBox.value).isAtLeast(small.iconBox.value) assertThat(big.rowVPad.value).isAtLeast(small.rowVPad.value) + assertThat(big.dayHeaderTopPad.value).isAtLeast(small.dayHeaderTopPad.value) } } + + @Test + fun `the event title keeps its lead over the time line`() { + // The secondary line steps more slowly on purpose; if it ever caught up the + // row would lose its hierarchy. + WidgetScale.entries.map(::metricsFor).forEach { m -> + assertThat(m.eventTitle.value).isGreaterThan(m.eventTime.value) + } + } + + @Test + fun `no tier grows type more than half again over the baseline`() { + // Guards against a future edit turning "more readable" into "absurd". + val base = metricsFor(WidgetScale.COMPACT) + val top = metricsFor(WidgetScale.XLARGE) + assertThat(top.title.value / base.title.value).isLessThan(1.5f) + assertThat(top.eventTitle.value / base.eventTitle.value).isLessThan(1.5f) + } + + // --- font scale ---------------------------------------------------------- + + @Test + fun `the stripe tracks the system font scale`() { + // The stripe is Dp, the text beside it is sp: without this the two diverge + // at large accessibility font settings and the stripe under-runs the row. + val m = metricsFor(WidgetScale.COMPACT) + assertThat(m.scaledForFont(1f).stripeH).isEqualTo(36.dp) + assertThat(m.scaledForFont(1.3f).stripeH.value).isWithin(0.01f).of(46.8f) + assertThat(m.scaledForFont(0.85f).stripeH.value).isWithin(0.01f).of(30.6f) + } + + @Test + fun `scaling for the default font scale changes nothing`() { + val m = metricsFor(WidgetScale.LARGE) + assertThat(m.scaledForFont(1f)).isSameInstanceAs(m) + } + + @Test + fun `font scaling leaves the sp sizes alone`() { + // Glance already applies the font scale to sp; scaling them here too would + // double-count it. + val m = metricsFor(WidgetScale.REGULAR) + val scaled = m.scaledForFont(1.3f) + assertThat(scaled.title).isEqualTo(m.title) + assertThat(scaled.eventTitle).isEqualTo(m.eventTitle) + assertThat(scaled.rowVPad).isEqualTo(m.rowVPad) + } } From 98d76deee53f17f55a24a28ce27e0b9185a0052d Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Mon, 20 Jul 2026 15:20:15 +0200 Subject: [PATCH 34/78] fix(edit): correct the recurrence picker's read-out and end handling MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review follow-ups to the picker redesign, plus the bugs it exposed. The live read-out now renders customResult itself — the exact string OK would save — instead of rebuilding a parallel rule with `interval ?: 1` and `end ?: Never` fallbacks, which let it confidently describe a rule that differed from the selected controls whenever a field was invalid. Shrink the invalid space behind it: a blank amount field reads as its visible placeholder (1 / 10) rather than as an error, and backing out of the date picker falls back to "never" instead of stranding a dateless "on a date". Only an out-of-range 0 remains invalid, and that now says so rather than greying out OK with no cause. UNTIL displayed the day after the one picked for zones behind UTC: toRRule deliberately writes the end of the chosen *local* day expressed in UTC (the provider applies UNTIL coarsely), so the read side must convert back before taking the date. Fixes the detail screen too, and untilLocalDate is extracted so it can be tested. Also: hoist a remember() out of a conditional (a slot that appears and disappears breaks positional memoisation), match GroupedSurface's 22dp corners instead of a drifted local 20dp copy, move the cards onto GroupedSurface, drop the segmented row's icon slot so longer unit labels fit, and reserve two lines so the stack stops shifting as the phrase grows with each weekday. Extract SettingsPrefs.firstDayOfWeek(scope), replacing three copies that had drifted onto different initialValues. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../calendula/data/prefs/SettingsPrefs.kt | 22 +++ .../calendula/ui/agenda/AgendaViewModel.kt | 7 +- .../calendula/ui/common/RecurrenceText.kt | 40 ++++-- .../calendula/ui/edit/EventEditScreen.kt | 125 ++++++++++-------- .../calendula/ui/edit/EventEditViewModel.kt | 12 +- .../calendula/ui/month/MonthViewModel.kt | 13 +- app/src/main/res/values/strings.xml | 3 + .../calendula/ui/common/RecurrenceTextTest.kt | 69 ++++++++++ 8 files changed, 205 insertions(+), 86 deletions(-) create mode 100644 app/src/test/java/de/jeanlucmakiola/calendula/ui/common/RecurrenceTextTest.kt diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/data/prefs/SettingsPrefs.kt b/app/src/main/java/de/jeanlucmakiola/calendula/data/prefs/SettingsPrefs.kt index 1026f2e..316e33d 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/data/prefs/SettingsPrefs.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/data/prefs/SettingsPrefs.kt @@ -23,8 +23,12 @@ import de.jeanlucmakiola.calendula.ui.common.IMPLEMENTED_VIEWS import de.jeanlucmakiola.calendula.ui.common.QuickSwitchConfig import de.jeanlucmakiola.calendula.ui.theme.FONT_SYSTEM_TOKEN import java.time.ZoneId +import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.stateIn import kotlinx.datetime.DayOfWeek import java.time.temporal.WeekFields import java.util.Locale @@ -82,6 +86,24 @@ fun WeekStartPref.resolveFirstDay(locale: Locale): DayOfWeek = when (this) { WeekStartPref.Auto -> DayOfWeek(WeekFields.of(locale).firstDayOfWeek.value) } +/** + * The resolved first day of the week as a hot [StateFlow] — the one shape every + * screen that orders or lays out weekdays should use (the month grid, the agenda + * "this week" range, the recurrence weekday toggles). Sharing it keeps the + * initial-frame value identical everywhere; hand-rolled copies had drifted onto + * different `initialValue`s, so two surfaces could disagree for a frame. + */ +fun SettingsPrefs.firstDayOfWeek(scope: CoroutineScope): StateFlow = + weekStart + .map { it.resolveFirstDay(Locale.getDefault()) } + .stateIn( + scope = scope, + started = SharingStarted.WhileSubscribed(5_000L), + // Seed with the locale convention rather than a hardcoded weekday, so + // the first frame is already right for everyone still on Auto. + initialValue = WeekStartPref.Auto.resolveFirstDay(Locale.getDefault()), + ) + /** * Display settings (M4) persisted app-side: theme override, Material You * dynamic colour, and week start. Language is handled separately through 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 924f6b8..bc507c3 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 @@ -7,7 +7,7 @@ import de.jeanlucmakiola.calendula.data.calendar.CalendarRepository import de.jeanlucmakiola.calendula.data.di.IoDispatcher import de.jeanlucmakiola.calendula.data.prefs.PastEventDisplay import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs -import de.jeanlucmakiola.calendula.data.prefs.resolveFirstDay +import de.jeanlucmakiola.calendula.data.prefs.firstDayOfWeek import de.jeanlucmakiola.calendula.domain.CalendarSource import de.jeanlucmakiola.calendula.domain.EventInstance import de.jeanlucmakiola.calendula.domain.FailureReason @@ -20,13 +20,11 @@ import kotlinx.coroutines.flow.catch import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.flowOn -import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.stateIn import kotlinx.datetime.DateTimeUnit import kotlinx.datetime.DayOfWeek import kotlinx.datetime.LocalDate import kotlinx.datetime.TimeZone -import java.util.Locale import kotlinx.datetime.atStartOfDayIn import kotlinx.datetime.atTime import kotlinx.datetime.plus @@ -51,8 +49,7 @@ class AgendaViewModel @Inject constructor( ) { range, showBar -> AgendaSettings(range, showBar) } // First day of the week, for the calendar-aligned "this week" range. - private val weekStartDay = settingsPrefs.weekStart - .map { it.resolveFirstDay(Locale.getDefault()) } + private val weekStartDay = settingsPrefs.firstDayOfWeek(viewModelScope) /** * How to treat events that already ended today (show / dim / hide). A display diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/RecurrenceText.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/RecurrenceText.kt index 2c930b8..2ce12c9 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/RecurrenceText.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/RecurrenceText.kt @@ -9,6 +9,10 @@ import androidx.compose.ui.text.buildAnnotatedString import androidx.compose.ui.text.font.FontStyle import de.jeanlucmakiola.calendula.R import java.time.DayOfWeek +import java.time.LocalDate +import java.time.LocalDateTime +import java.time.ZoneId +import java.time.ZoneOffset import java.time.format.DateTimeFormatter import java.time.format.FormatStyle import java.time.format.TextStyle as JavaTextStyle @@ -111,16 +115,36 @@ private fun rruleDayName(token: String, locale: Locale): String? { /** Parse an RRULE UNTIL value ("20261231" or "20261231T235959Z") to a localized date. */ private fun parseUntilDate(raw: String, locale: Locale): String? { - val digits = raw.takeWhile { it.isDigit() } - if (digits.length < 8) return null - return try { - val date = java.time.LocalDate.of( + val date = untilLocalDate(raw) ?: return null + return DateTimeFormatter.ofLocalizedDate(FormatStyle.MEDIUM).withLocale(locale).format(date) +} + +private val UNTIL_UTC_FORMAT: DateTimeFormatter = + DateTimeFormatter.ofPattern("yyyyMMdd'T'HHmmss'Z'", Locale.ROOT) + +/** + * The calendar day an RRULE UNTIL value denotes, *in the device's zone*. + * + * The UTC form ("20261231T225959Z") must be converted back before its date is + * read: `SimpleRecurrence.toRRule` writes the end of the chosen **local** day + * expressed in UTC, so for zones behind UTC that instant already falls on the + * following UTC date. Taking the leading digits straight off would then show + * the day after the one the user picked. Date-only and floating forms are + * already local and pass through unchanged. + */ +internal fun untilLocalDate(raw: String, zone: ZoneId = ZoneId.systemDefault()): LocalDate? { + val value = raw.trim() + return runCatching { + LocalDateTime.parse(value, UNTIL_UTC_FORMAT) + .atOffset(ZoneOffset.UTC) + .atZoneSameInstant(zone) + .toLocalDate() + }.recoverCatching { + val digits = value.takeWhile { it.isDigit() } + LocalDate.of( digits.substring(0, 4).toInt(), digits.substring(4, 6).toInt(), digits.substring(6, 8).toInt(), ) - DateTimeFormatter.ofLocalizedDate(FormatStyle.MEDIUM).withLocale(locale).format(date) - } catch (e: Exception) { - null - } + }.getOrNull() } diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/edit/EventEditScreen.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/edit/EventEditScreen.kt index aa569b0..13dd8ed 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/edit/EventEditScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/edit/EventEditScreen.kt @@ -99,7 +99,6 @@ import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.text.input.KeyboardCapitalization import androidx.compose.ui.text.input.KeyboardType -import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import androidx.core.content.ContextCompat import androidx.hilt.navigation.compose.hiltViewModel @@ -140,11 +139,13 @@ import de.jeanlucmakiola.calendula.ui.common.eventFormFieldIcon import de.jeanlucmakiola.calendula.ui.common.eventFormFieldLabel import de.jeanlucmakiola.floret.components.FullScreenPicker import de.jeanlucmakiola.floret.components.GroupedRow +import de.jeanlucmakiola.floret.components.GroupedSurface import de.jeanlucmakiola.calendula.ui.common.MILLIS_PER_DAY import de.jeanlucmakiola.floret.components.InlineTextField import de.jeanlucmakiola.floret.components.OptionCard import de.jeanlucmakiola.floret.components.OptionPicker import de.jeanlucmakiola.floret.components.Position +import de.jeanlucmakiola.floret.components.groupedShape as floretGroupedShape import de.jeanlucmakiola.floret.components.positionOf import de.jeanlucmakiola.calendula.ui.common.REMINDER_PRESETS import de.jeanlucmakiola.calendula.ui.common.TimePickerAlert @@ -1328,8 +1329,21 @@ private fun RecurrencePickerDialog( val locale = currentLocale() val untilDate = untilIso?.let { runCatching { LocalDate.parse(it) }.getOrNull() } - val interval = intervalText.toIntOrNull()?.takeIf { it in 1..999 } - val count = countText.toIntOrNull()?.takeIf { it in 1..999 } + // remember() must not sit behind the ?. — a slot that appears and disappears + // as a date is picked breaks Compose's positional memoisation. Format + // unconditionally and let the null fall through to a null summary. + val untilSummary = remember(untilDate, locale) { + untilDate?.let { + DateTimeFormatter.ofLocalizedDate(FormatStyle.MEDIUM) + .withLocale(locale).format(it.toJavaLocalDate()) + } + } + // A blank amount field shows its placeholder ("1" / "10") greyed out, so read + // blank as that default rather than as an error — otherwise simply clearing + // the field silently greys out OK. Only a real out-of-range value (0) is + // invalid, and that's the one case the read-out below reports. + val interval = if (intervalText.isBlank()) 1 else intervalText.toIntOrNull()?.takeIf { it in 1..999 } + val count = if (countText.isBlank()) 10 else countText.toIntOrNull()?.takeIf { it in 1..999 } val customEnd: RecurrenceEnd? = when (endMode) { RecurrenceEndMode.Never -> RecurrenceEnd.Never RecurrenceEndMode.Until -> untilDate?.let { RecurrenceEnd.Until(it) } @@ -1387,22 +1401,25 @@ private fun RecurrencePickerDialog( // a parent arrangement would leave a residual gap that snaps shut. Column(modifier = Modifier.fillMaxWidth()) { // A live, human-readable read-out of the rule being built, so the - // effect of the controls below is never a guess. + // effect of the controls below is never a guess. It renders + // *[customResult] itself* — the exact string OK would save — so it + // can never describe a different rule than the one that lands. When + // the form can't produce a rule, it says why instead of falling + // back to defaults and quietly disagreeing with the controls. + // minLines reserves the second line up front: without it the whole + // stack below shifts a line as the phrase grows with each weekday. Text( - text = recurrenceText( - SimpleRecurrence( - freq = freq, - interval = interval ?: 1, - end = customEnd ?: RecurrenceEnd.Never, - byDays = if (freq == RecurrenceFreq.Weekly) { - daysMask.toDaySet() - } else { - emptySet() - }, - ).toRRule(), - locale, - ), + text = customResult?.let { recurrenceText(it, locale) } + ?: AnnotatedString( + stringResource(R.string.event_edit_recurrence_incomplete), + ), style = MaterialTheme.typography.titleMedium, + color = if (customResult != null) { + MaterialTheme.colorScheme.onSurface + } else { + MaterialTheme.colorScheme.error + }, + minLines = 2, modifier = Modifier .fillMaxWidth() .padding(horizontal = 16.dp), @@ -1410,11 +1427,9 @@ private fun RecurrencePickerDialog( // How often: an interval amount plus a frequency segmented row — // all four units on show, no unit hidden behind a dropdown. - Surface( - color = MaterialTheme.colorScheme.surfaceContainerHigh, - shape = groupedShape(Position.Alone), + GroupedSurface( + position = Position.Alone, modifier = Modifier - .fillMaxWidth() .padding(top = 16.dp) .padding(horizontal = 16.dp), ) { @@ -1442,7 +1457,18 @@ private fun RecurrencePickerDialog( shape = SegmentedButtonDefaults.itemShape( index, RecurrenceFreq.entries.size, ), - label = { Text(stringResource(recurrenceUnitLabel(entry))) }, + // Drop the default check-icon slot: four segments + // share one row, and its reserved width is what + // pushes longer labels ("Monate", "Wochen") into + // an ellipsis on a compact screen. Selection is + // already carried by the segment's fill. + icon = {}, + label = { + Text( + text = stringResource(recurrenceUnitLabel(entry)), + maxLines = 1, + ) + }, ) } } @@ -1452,11 +1478,9 @@ private fun RecurrencePickerDialog( // Weekday picks — only meaningful on a weekly rule. The 16dp gap // sits inside the animated block so it grows/shrinks with the card. AnimatedVisibility(visible = freq == RecurrenceFreq.Weekly) { - Surface( - color = MaterialTheme.colorScheme.surfaceContainerHigh, - shape = groupedShape(Position.Alone), + GroupedSurface( + position = Position.Alone, modifier = Modifier - .fillMaxWidth() .padding(top = 16.dp) .padding(horizontal = 16.dp), ) { @@ -1487,12 +1511,7 @@ private fun RecurrencePickerDialog( ) GroupedRow( title = stringResource(R.string.event_edit_recurrence_end_until), - summary = untilDate?.let { - remember(it, locale) { - DateTimeFormatter.ofLocalizedDate(FormatStyle.MEDIUM) - .withLocale(locale).format(it.toJavaLocalDate()) - } - }, + summary = untilSummary, position = Position.Middle, selected = endMode == RecurrenceEndMode.Until, onClick = { @@ -1510,13 +1529,13 @@ private fun RecurrencePickerDialog( selected = endMode == RecurrenceEndMode.Count, onClick = { endMode = RecurrenceEndMode.Count }, ) - if (endMode == RecurrenceEndMode.Count) { - Surface( - color = MaterialTheme.colorScheme.surfaceContainerHigh, - shape = groupedShape(Position.Bottom), - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 16.dp), + // Animated for the same reason the weekday card is: this is the + // picker's other collapsible block, and a bare `if` here made + // the ends group snap while the card above it glided. + AnimatedVisibility(visible = endMode == RecurrenceEndMode.Count) { + GroupedSurface( + position = Position.Bottom, + modifier = Modifier.padding(horizontal = 16.dp), ) { Row( verticalAlignment = Alignment.CenterVertically, @@ -1549,7 +1568,13 @@ private fun RecurrencePickerDialog( untilIso = it.toString() showUntilPicker = false }, - onDismiss = { showUntilPicker = false }, + onDismiss = { + showUntilPicker = false + // Backing out of the date picker with nothing chosen would leave + // "on date" selected but dateless — a state that can't be saved and + // greys out OK with no visible cause. Fall back to "never". + if (untilIso == null) endMode = RecurrenceEndMode.Never + }, ) } } @@ -1625,18 +1650,14 @@ private fun recurrenceUnitLabel(freq: RecurrenceFreq): Int = when (freq) { RecurrenceFreq.Yearly -> R.string.recurrence_unit_years } -/** Corner shape for a card at [position] in a grouped run (mirrors GroupedRow). */ -private fun groupedShape(position: Position, full: Dp = 20.dp, small: Dp = 6.dp): Shape = - when (position) { - Position.Alone -> RoundedCornerShape(full) - Position.Top -> RoundedCornerShape( - topStart = full, topEnd = full, bottomStart = small, bottomEnd = small, - ) - Position.Middle -> RoundedCornerShape(small) - Position.Bottom -> RoundedCornerShape( - topStart = small, topEnd = small, bottomStart = full, bottomEnd = full, - ) - } +/** + * Corner shape for a card at [position] in a grouped run. Delegates to the + * family primitive with the same radii [GroupedSurface] draws at rest — a local + * copy had drifted to 20dp, so a card sharing a run with GroupedRows had + * visibly tighter outer corners than the rows above it. + */ +private fun groupedShape(position: Position): Shape = + floretGroupedShape(position, full = 22.dp, small = 6.dp) /** The 2dp gap that visually separates grouped cards (none after the last). */ private fun groupedGap(position: Position): Modifier = when (position) { diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/edit/EventEditViewModel.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/edit/EventEditViewModel.kt index dc4e469..45ae36f 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/edit/EventEditViewModel.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/edit/EventEditViewModel.kt @@ -8,9 +8,8 @@ import de.jeanlucmakiola.calendula.data.calendar.NoSuchEventException import de.jeanlucmakiola.calendula.data.di.IoDispatcher import de.jeanlucmakiola.calendula.data.prefs.CalendarPrefs import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs -import de.jeanlucmakiola.calendula.data.prefs.WeekStartPref +import de.jeanlucmakiola.calendula.data.prefs.firstDayOfWeek import de.jeanlucmakiola.calendula.data.prefs.resolveDefaultReminder -import de.jeanlucmakiola.calendula.data.prefs.resolveFirstDay import de.jeanlucmakiola.calendula.domain.AccessLevel import de.jeanlucmakiola.calendula.domain.Availability import de.jeanlucmakiola.calendula.domain.CalendarSource @@ -49,7 +48,6 @@ import kotlinx.datetime.LocalTime import kotlinx.datetime.TimeZone import kotlinx.datetime.toInstant import kotlinx.datetime.toLocalDateTime -import java.util.Locale import kotlin.coroutines.cancellation.CancellationException import kotlin.time.Clock import kotlin.time.Duration.Companion.hours @@ -276,13 +274,7 @@ class EventEditViewModel @Inject constructor( * app's "week starts on" preference, matching the calendar views (Auto * falls back to the locale convention). */ - val firstDayOfWeek: StateFlow = settingsPrefs.weekStart - .map { it.resolveFirstDay(Locale.getDefault()) } - .stateIn( - scope = viewModelScope, - started = SharingStarted.WhileSubscribed(5_000L), - initialValue = WeekStartPref.Auto.resolveFirstDay(Locale.getDefault()), - ) + val firstDayOfWeek: StateFlow = settingsPrefs.firstDayOfWeek(viewModelScope) /** * Initialise a fresh form for a new event on [date]. [startMinutes] (minutes 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 de51d4e..8abd2f0 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 @@ -6,7 +6,7 @@ import dagger.hilt.android.lifecycle.HiltViewModel import de.jeanlucmakiola.calendula.data.calendar.CalendarRepository import de.jeanlucmakiola.calendula.data.di.IoDispatcher import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs -import de.jeanlucmakiola.calendula.data.prefs.resolveFirstDay +import de.jeanlucmakiola.calendula.data.prefs.firstDayOfWeek import de.jeanlucmakiola.calendula.domain.CalendarSource import de.jeanlucmakiola.calendula.domain.EventInstance import de.jeanlucmakiola.calendula.domain.FailureReason @@ -21,7 +21,6 @@ import kotlinx.coroutines.flow.catch import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.flowOn -import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.stateIn import kotlinx.datetime.DateTimeUnit import kotlinx.datetime.DayOfWeek @@ -34,7 +33,6 @@ import kotlinx.datetime.minus import kotlinx.datetime.plus import kotlinx.datetime.toInstant import kotlinx.datetime.toLocalDateTime -import java.util.Locale import kotlin.time.Clock import kotlin.time.Instant import javax.inject.Inject @@ -48,16 +46,9 @@ class MonthViewModel @Inject constructor( ) : ViewModel() { private val zone = TimeZone.currentSystemDefault() - private val locale: Locale = Locale.getDefault() /** First day of the week, from the Settings preference (AUTO → locale). */ - val weekStart: StateFlow = settingsPrefs.weekStart - .map { it.resolveFirstDay(locale) } - .stateIn( - scope = viewModelScope, - started = SharingStarted.WhileSubscribed(5_000L), - initialValue = DayOfWeek.MONDAY, - ) + val weekStart: StateFlow = settingsPrefs.firstDayOfWeek(viewModelScope) /** Whether to fade events that have already finished (display concern only). */ val dimCompletedEvents: StateFlow = settingsPrefs.dimCompletedEvents diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 136c4e9..791685f 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -149,6 +149,9 @@ weeks months years + + Enter a number from 1 to 999 Ends Never On a date diff --git a/app/src/test/java/de/jeanlucmakiola/calendula/ui/common/RecurrenceTextTest.kt b/app/src/test/java/de/jeanlucmakiola/calendula/ui/common/RecurrenceTextTest.kt new file mode 100644 index 0000000..213d31d --- /dev/null +++ b/app/src/test/java/de/jeanlucmakiola/calendula/ui/common/RecurrenceTextTest.kt @@ -0,0 +1,69 @@ +package de.jeanlucmakiola.calendula.ui.common + +import com.google.common.truth.Truth.assertThat +import java.time.LocalDate +import java.time.ZoneId +import org.junit.jupiter.api.Test + +/** + * The read side of the UNTIL round-trip. `SimpleRecurrence.toRRule` deliberately + * writes the end of the chosen *local* day expressed in UTC (see its docs — the + * provider applies UNTIL coarsely), so displaying it means converting back into + * the device zone first. Reading the leading digits raw showed the wrong day for + * zones behind UTC. + */ +class RecurrenceTextTest { + + private val berlin = ZoneId.of("Europe/Berlin") + private val losAngeles = ZoneId.of("America/Los_Angeles") + private val utc = ZoneId.of("UTC") + + @Test + fun `UTC form converts back to the picked day west of UTC`() { + // toRRule("2026-12-31", America/Los_Angeles) → 23:59:59 local = 07:59:59Z + // on 1 Jan. Reading the digits raw would show 1 Jan 2027. + assertThat(untilLocalDate("20270101T075959Z", losAngeles)) + .isEqualTo(LocalDate.of(2026, 12, 31)) + } + + @Test + fun `UTC form converts back to the picked day east of UTC`() { + // Berlin in December is UTC+1: 23:59:59 local = 22:59:59Z the same day. + assertThat(untilLocalDate("20261231T225959Z", berlin)) + .isEqualTo(LocalDate.of(2026, 12, 31)) + } + + @Test + fun `UTC form is unchanged at UTC itself`() { + assertThat(untilLocalDate("20261231T235959Z", utc)) + .isEqualTo(LocalDate.of(2026, 12, 31)) + } + + @Test + fun `summer offset is honoured, not a fixed one`() { + // Berlin in July is UTC+2, so the same local end-of-day lands at 21:59:59Z. + assertThat(untilLocalDate("20260801T215959Z", berlin)) + .isEqualTo(LocalDate.of(2026, 8, 1)) + } + + @Test + fun `date-only form is already local and passes through`() { + assertThat(untilLocalDate("20261231", losAngeles)) + .isEqualTo(LocalDate.of(2026, 12, 31)) + } + + @Test + fun `floating date-time form uses its date as-is`() { + // No trailing Z, so it isn't an instant — no conversion may be applied. + assertThat(untilLocalDate("20261231T235959", losAngeles)) + .isEqualTo(LocalDate.of(2026, 12, 31)) + } + + @Test + fun `garbage returns null rather than throwing`() { + assertThat(untilLocalDate("", berlin)).isNull() + assertThat(untilLocalDate("nonsense", berlin)).isNull() + assertThat(untilLocalDate("2026", berlin)).isNull() + assertThat(untilLocalDate("20261340", berlin)).isNull() + } +} From 463811056ded88dd3cefbeb47cc670a33916024e Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Mon, 20 Jul 2026 15:20:37 +0200 Subject: [PATCH 35/78] refactor(ui): one selection check and one motion spec across the pickers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three inconsistencies the recurrence-picker review surfaced, all of the same kind: the redesigned picker had quietly diverged from the family. SelectedCheck existed as five copies app-side plus a private one in the kit, and they had drifted — the kit drew Icons.Rounded.Check, every app copy drew Icons.Default.Check. Delete all five against the kit's now public primitive (floret-kit!2), which settles the glyph on Rounded. The recurrence picker was the only full-screen picker whose selected row carried no check, relying on the tonal highlight alone. Add it to all six rows, matching OptionPicker, reminder, agenda range, timezone, calendar and Settings. Its two new AnimatedVisibility blocks were the only ones in the app using Compose's bare defaults rather than expandEnter()/collapseExit(). Those helpers honour rememberReduceMotion(), so the weekday and count cards were ignoring the system "remove animations" setting — an accessibility regression, not just a style drift. Bumps the floret-kit pointer; needs floret-kit!2 merged first. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../ui/common/CalendarPickerGroups.kt | 10 +---- .../calendula/ui/common/Picker.kt | 10 +---- .../calendula/ui/common/TimeZonePicker.kt | 10 +---- .../calendula/ui/edit/EventEditScreen.kt | 43 ++++++++++++++++++- .../calendula/ui/settings/SettingsScreen.kt | 18 ++------ floret-kit | 2 +- 6 files changed, 49 insertions(+), 44 deletions(-) diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/CalendarPickerGroups.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/CalendarPickerGroups.kt index d46ad50..8a25102 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/CalendarPickerGroups.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/CalendarPickerGroups.kt @@ -11,7 +11,6 @@ import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.size import androidx.compose.foundation.shape.CircleShape import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Check import androidx.compose.material.icons.filled.Cloud import androidx.compose.material.icons.filled.PhoneAndroid import androidx.compose.material3.Icon @@ -33,6 +32,7 @@ import de.jeanlucmakiola.calendula.R import de.jeanlucmakiola.calendula.domain.CalendarSource import de.jeanlucmakiola.floret.components.GroupedRow import de.jeanlucmakiola.floret.components.Position +import de.jeanlucmakiola.floret.components.SelectedCheck /** * The app's single "which calendar" selection list, shared by the event editor @@ -99,13 +99,7 @@ private fun CalendarPickerGroup( selected = isSelected, leading = { CalendarColorChip(calendar.color) }, trailing = if (isSelected) { - { - Icon( - imageVector = Icons.Default.Check, - contentDescription = null, - tint = MaterialTheme.colorScheme.primary, - ) - } + { SelectedCheck() } } else { null }, diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/Picker.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/Picker.kt index 1aefd87..c038dee 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/Picker.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/Picker.kt @@ -5,7 +5,6 @@ import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Check import androidx.compose.material3.Checkbox import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme @@ -26,6 +25,7 @@ import de.jeanlucmakiola.floret.components.CustomAmountEditor import de.jeanlucmakiola.floret.components.FullScreenPicker import de.jeanlucmakiola.floret.components.GroupedRow import de.jeanlucmakiola.floret.components.Position +import de.jeanlucmakiola.floret.components.SelectedCheck import de.jeanlucmakiola.floret.components.positionOf import de.jeanlucmakiola.floret.identity.collapseExit import de.jeanlucmakiola.floret.identity.expandEnter @@ -207,14 +207,6 @@ private fun PickerDescription(text: String) { ) } -@Composable -private fun SelectedCheck() { - Icon( - imageVector = Icons.Default.Check, - contentDescription = null, - tint = MaterialTheme.colorScheme.primary, - ) -} /** * Agenda-range picker, full-screen. Two grouped lists — the calendar-aligned diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/TimeZonePicker.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/TimeZonePicker.kt index fc5d472..cf20da7 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/TimeZonePicker.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/TimeZonePicker.kt @@ -9,7 +9,6 @@ import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Check import androidx.compose.material.icons.filled.Close import androidx.compose.material.icons.filled.Public import androidx.compose.material.icons.filled.Search @@ -41,6 +40,7 @@ import de.jeanlucmakiola.floret.components.FullScreenPicker import de.jeanlucmakiola.floret.components.GroupedRow import de.jeanlucmakiola.floret.components.InlineTextField import de.jeanlucmakiola.floret.components.Position +import de.jeanlucmakiola.floret.components.SelectedCheck import de.jeanlucmakiola.floret.components.positionOf import de.jeanlucmakiola.floret.locale.currentLocale @@ -223,14 +223,6 @@ private fun SectionHeader(text: String) { ) } -@Composable -private fun SelectedCheck() { - Icon( - imageVector = Icons.Default.Check, - contentDescription = null, - tint = MaterialTheme.colorScheme.primary, - ) -} @Composable private fun ZoneRow( diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/edit/EventEditScreen.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/edit/EventEditScreen.kt index 13dd8ed..019c830 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/edit/EventEditScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/edit/EventEditScreen.kt @@ -145,6 +145,7 @@ import de.jeanlucmakiola.floret.components.InlineTextField import de.jeanlucmakiola.floret.components.OptionCard import de.jeanlucmakiola.floret.components.OptionPicker import de.jeanlucmakiola.floret.components.Position +import de.jeanlucmakiola.floret.components.SelectedCheck import de.jeanlucmakiola.floret.components.groupedShape as floretGroupedShape import de.jeanlucmakiola.floret.components.positionOf import de.jeanlucmakiola.calendula.ui.common.REMINDER_PRESETS @@ -1379,6 +1380,11 @@ private fun RecurrencePickerDialog( title = stringResource(R.string.event_edit_recurrence_none), position = positionOf(0, rowCount), selected = current == null, + trailing = if (current == null) { + { SelectedCheck() } + } else { + null + }, onClick = { onSelect(null) }, ) RecurrenceFreq.entries.forEachIndexed { index, entry -> @@ -1386,6 +1392,11 @@ private fun RecurrencePickerDialog( title = stringResource(recurrencePresetLabel(entry)), position = positionOf(index + 1, rowCount), selected = isPlainPreset && parsed?.freq == entry, + trailing = if (isPlainPreset && parsed?.freq == entry) { + { SelectedCheck() } + } else { + null + }, onClick = { onSelect(SimpleRecurrence(entry).toRRule()) }, ) } @@ -1393,6 +1404,11 @@ private fun RecurrencePickerDialog( title = stringResource(R.string.event_edit_recurrence_custom), position = positionOf(rowCount - 1, rowCount), selected = current != null && !isPlainPreset, + trailing = if (current != null && !isPlainPreset) { + { SelectedCheck() } + } else { + null + }, onClick = { customMode = true }, ) } else { @@ -1477,7 +1493,11 @@ private fun RecurrencePickerDialog( // Weekday picks — only meaningful on a weekly rule. The 16dp gap // sits inside the animated block so it grows/shrinks with the card. - AnimatedVisibility(visible = freq == RecurrenceFreq.Weekly) { + AnimatedVisibility( + visible = freq == RecurrenceFreq.Weekly, + enter = expandEnter(), + exit = collapseExit(), + ) { GroupedSurface( position = Position.Alone, modifier = Modifier @@ -1507,6 +1527,11 @@ private fun RecurrencePickerDialog( title = stringResource(R.string.event_edit_recurrence_end_never), position = Position.Top, selected = endMode == RecurrenceEndMode.Never, + trailing = if (endMode == RecurrenceEndMode.Never) { + { SelectedCheck() } + } else { + null + }, onClick = { endMode = RecurrenceEndMode.Never }, ) GroupedRow( @@ -1514,6 +1539,11 @@ private fun RecurrencePickerDialog( summary = untilSummary, position = Position.Middle, selected = endMode == RecurrenceEndMode.Until, + trailing = if (endMode == RecurrenceEndMode.Until) { + { SelectedCheck() } + } else { + null + }, onClick = { endMode = RecurrenceEndMode.Until showUntilPicker = true @@ -1527,12 +1557,21 @@ private fun RecurrencePickerDialog( Position.Bottom }, selected = endMode == RecurrenceEndMode.Count, + trailing = if (endMode == RecurrenceEndMode.Count) { + { SelectedCheck() } + } else { + null + }, onClick = { endMode = RecurrenceEndMode.Count }, ) // Animated for the same reason the weekday card is: this is the // picker's other collapsible block, and a bare `if` here made // the ends group snap while the card above it glided. - AnimatedVisibility(visible = endMode == RecurrenceEndMode.Count) { + AnimatedVisibility( + visible = endMode == RecurrenceEndMode.Count, + enter = expandEnter(), + exit = collapseExit(), + ) { GroupedSurface( position = Position.Bottom, modifier = Modifier.padding(horizontal = 16.dp), diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/SettingsScreen.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/SettingsScreen.kt index 7339a75..712635b 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/SettingsScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/SettingsScreen.kt @@ -43,7 +43,6 @@ import androidx.compose.material.icons.filled.BugReport import androidx.compose.material.icons.filled.Cake import androidx.compose.material.icons.filled.CalendarMonth import androidx.compose.material.icons.filled.Dashboard -import androidx.compose.material.icons.filled.Check import androidx.compose.material.icons.filled.DragHandle import androidx.compose.material.icons.filled.SwapVert import androidx.compose.material.icons.filled.ExpandLess @@ -131,6 +130,7 @@ import de.jeanlucmakiola.floret.components.Position import de.jeanlucmakiola.calendula.ui.common.REMINDER_PRESETS import de.jeanlucmakiola.calendula.ui.common.ReminderDefaultPicker import de.jeanlucmakiola.calendula.ui.common.TimePickerAlert +import de.jeanlucmakiola.floret.components.SelectedCheck import de.jeanlucmakiola.floret.components.positionOf import de.jeanlucmakiola.calendula.ui.common.reminderLeadTimeLabel import de.jeanlucmakiola.calendula.ui.common.SnoozeDurationPicker @@ -1093,13 +1093,7 @@ private fun NotificationsScreen( }, position = Position.Top, trailing = if (batteryExempt) { - { - Icon( - imageVector = Icons.Default.Check, - contentDescription = null, - tint = MaterialTheme.colorScheme.primary, - ) - } + { SelectedCheck() } } else { null }, @@ -1845,13 +1839,7 @@ private fun FontOptionRow( } }, trailing = if (selected) { - { - Icon( - imageVector = Icons.Default.Check, - contentDescription = null, - tint = MaterialTheme.colorScheme.primary, - ) - } + { SelectedCheck() } } else { null }, diff --git a/floret-kit b/floret-kit index df8bdba..b495471 160000 --- a/floret-kit +++ b/floret-kit @@ -1 +1 @@ -Subproject commit df8bdbaf73380a354b7d2ee28e08875e155c89cd +Subproject commit b4954713ff2a476b8b4e4c1f93d9267547a757e4 From 6a454e0b33ce235e4bd61210591fb47cfae22d38 Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Mon, 20 Jul 2026 15:28:05 +0200 Subject: [PATCH 36/78] chore: point floret-kit at merged main MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit floret-kit!2 (public SelectedCheck) is merged, so the submodule no longer needs to track the feature branch. Same tree, so nothing rebuilds — this only stops Calendula pinning a branch that is now deletable. Co-Authored-By: Claude Opus 4.8 (1M context) --- floret-kit | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/floret-kit b/floret-kit index b495471..75b90d1 160000 --- a/floret-kit +++ b/floret-kit @@ -1 +1 @@ -Subproject commit b4954713ff2a476b8b4e4c1f93d9267547a757e4 +Subproject commit 75b90d1b3b7b1d6e050cc0ee961d4a8867abd60d From 498250650c3c320ff13a5ef703e1551dcb1df183 Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Mon, 20 Jul 2026 16:36:57 +0200 Subject: [PATCH 37/78] refactor(month): extract per-week layout and share the agenda's rows MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Groundwork for the month view style setting (#38, #53); no behaviour change. - Split layoutCalendarWeek out of layoutMonthWeeks so the continuous style, which streams weeks and has no enclosing YearMonth to slice by, lays rows out identically. layoutMonthWeeks (also used by the month widget) keeps its signature and becomes a loop over it. - Add MonthUiState.Success.instancesByDay: the grid's events keyed by date and uncapped, so the split style's day pane can list a date without a second provider query — the month grid range already covers it. - Move AgendaDayHeader / AgendaEmptyDayRow / AgendaEventRow and their label helpers into AgendaRows.kt as internal, so the split pane reuses the agenda's row vocabulary instead of growing a parallel one. AgendaEmptyDayRow takes its text as a parameter now that it serves more than "nothing left today". - Add the month package's first JVM tests, covering week counts, span continuation across row boundaries, lane stacking and instancesByDay. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../calendula/ui/agenda/AgendaRows.kt | 206 ++++++++++++++++ .../calendula/ui/agenda/AgendaScreen.kt | 180 +------------- .../calendula/ui/month/MonthUiState.kt | 8 + .../calendula/ui/month/MonthViewModel.kt | 80 +++++-- .../calendula/ui/month/MonthLayoutTest.kt | 220 ++++++++++++++++++ 5 files changed, 495 insertions(+), 199 deletions(-) create mode 100644 app/src/main/java/de/jeanlucmakiola/calendula/ui/agenda/AgendaRows.kt create mode 100644 app/src/test/java/de/jeanlucmakiola/calendula/ui/month/MonthLayoutTest.kt diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/agenda/AgendaRows.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/agenda/AgendaRows.kt new file mode 100644 index 0000000..9aedc1c --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/agenda/AgendaRows.kt @@ -0,0 +1,206 @@ +package de.jeanlucmakiola.calendula.ui.agenda + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Coffee +import androidx.compose.material3.Card +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.draw.clip +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import de.jeanlucmakiola.calendula.R +import de.jeanlucmakiola.calendula.domain.EventInstance +import de.jeanlucmakiola.calendula.ui.common.EventDimAlpha +import de.jeanlucmakiola.calendula.ui.common.LocalSoftenColors +import de.jeanlucmakiola.calendula.ui.common.LocalUse24HourFormat +import de.jeanlucmakiola.calendula.ui.common.eventFill +import de.jeanlucmakiola.calendula.ui.common.formatTimeOfDay +import de.jeanlucmakiola.floret.components.GroupedRow +import de.jeanlucmakiola.floret.components.Position +import de.jeanlucmakiola.floret.locale.currentLocale +import de.jeanlucmakiola.floret.locale.localizedDateFormatter +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.util.Locale + +// The agenda's row vocabulary, split out of AgendaScreen so the month view's +// split style can list a day with exactly the same visual language instead of +// growing a parallel set of event rows. + +@Composable +internal fun AgendaDayHeader( + date: LocalDate, + today: LocalDate, + onOpenDay: (LocalDate) -> Unit, +) { + Surface( + color = MaterialTheme.colorScheme.surface, + modifier = Modifier + .fillMaxWidth() + .clickable { onOpenDay(date) }, + ) { + Text( + text = agendaDayLabel(date, today), + style = MaterialTheme.typography.titleSmall, + color = if (date == today) { + MaterialTheme.colorScheme.primary + } else { + MaterialTheme.colorScheme.onSurfaceVariant + }, + modifier = Modifier.padding(start = 28.dp, end = 28.dp, top = 16.dp, bottom = 8.dp), + ) + } +} + +/** + * A card standing in for a day with no events — the same coffee-cup motif as the + * agenda's full-screen empty state, boxed into a card so the day keeps a visible slot + * rather than a bare header. Used for an anchored, event-less today (#35) and for + * an empty selected day in the month view's split style. + */ +@Composable +internal fun AgendaEmptyDayRow(text: String, onClick: () -> Unit) { + Card( + // Match a single event row's resting corner radius (floret groupedShape). + shape = RoundedCornerShape(22.dp), + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 4.dp) + .clickable(onClick = onClick), + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 18.dp, horizontal = 16.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Icon( + imageVector = Icons.Filled.Coffee, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.size(36.dp), + ) + Spacer(Modifier.height(8.dp)) + Text( + text = text, + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center, + ) + } + } +} + +@Composable +internal fun AgendaEventRow( + event: EventInstance, + day: LocalDate, + zone: TimeZone, + position: Position, + dimmed: Boolean, + modifier: Modifier = Modifier, + onClick: () -> Unit, +) { + val dark = isSystemInDarkTheme() + val soften = LocalSoftenColors.current + val title = event.title.ifBlank { stringResource(R.string.event_untitled) } + GroupedRow( + modifier = if (dimmed) modifier.alpha(EventDimAlpha) else modifier, + title = title, + summary = agendaTimeSummary(event, day, zone), + position = position, + minHeight = 64.dp, + leading = { + Box( + modifier = Modifier + .size(width = 6.dp, height = 36.dp) + .clip(RoundedCornerShape(3.dp)) + .background(eventFill(event.color, dark, soften)), + ) + }, + onClick = onClick, + ) +} + +/** "Today · Wed, 17. Jun 2026" — relative word for today/tomorrow, else the date. */ +@Composable +internal fun agendaDayLabel(date: LocalDate, today: LocalDate): String { + val relative = when (date) { + today -> stringResource(R.string.agenda_header_today) + today.plus(1, DateTimeUnit.DAY) -> stringResource(R.string.agenda_header_tomorrow) + else -> null + } + val formatted = formatAgendaDate(date) + return if (relative != null) "$relative · $formatted" else formatted +} + +/** + * Time line under the title: "09:00 – 10:00 · Location", "All day", etc. + * + * A multi-day event shows only the part relevant to [day], spelled out so each + * day reads on its own: its first day names the start ("Starts 14:00"), its last + * day the end ("Ends 10:00"), and any whole day in between reads as "All day". + * An all-day multi-day event is simply "All day" on every day it covers. + */ +@Composable +internal fun agendaTimeSummary(event: EventInstance, day: LocalDate, zone: TimeZone): String { + val is24Hour = LocalUse24HourFormat.current + val locale = currentLocale() + + val time = when (val label = agendaTimeLabel(event, day, zone)) { + AgendaTimeLabel.AllDay -> stringResource(R.string.event_detail_all_day) + is AgendaTimeLabel.Starts -> stringResource( + R.string.agenda_span_starts, + formatTime(label.start, zone, is24Hour, locale), + ) + is AgendaTimeLabel.Ends -> stringResource( + R.string.agenda_span_ends, + formatTime(label.end, zone, is24Hour, locale), + ) + is AgendaTimeLabel.Range -> "${formatTime(label.start, zone, is24Hour, locale)} – " + + formatTime(label.end, zone, is24Hour, locale) + } + + val location = event.location?.takeIf { it.isNotBlank() } + return if (location != null) "$time · $location" else time +} + +private fun formatTime( + instant: Instant, + zone: TimeZone, + is24Hour: Boolean, + locale: Locale, +): String { + val t = instant.toLocalDateTime(zone).time + return formatTimeOfDay(t.hour, t.minute, is24Hour, locale) +} + +private fun formatAgendaDate(date: LocalDate): String { + val locale = Locale.getDefault() + val java = java.time.LocalDate.of(date.year, date.month.ordinal + 1, date.day) + // Weekday + date in the locale's own field order (e.g. "Wed, Jun 17, 2026" + // vs "Mi., 17. Juni 2026") rather than a hardcoded day-month-year layout. + return localizedDateFormatter(locale, "EEEdMMMy").format(java) +} diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/agenda/AgendaScreen.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/agenda/AgendaScreen.kt index f259929..83006f0 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/agenda/AgendaScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/agenda/AgendaScreen.kt @@ -1,9 +1,6 @@ package de.jeanlucmakiola.calendula.ui.agenda import androidx.compose.foundation.ExperimentalFoundationApi -import androidx.compose.foundation.background -import androidx.compose.foundation.clickable -import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -17,13 +14,11 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.itemsIndexed -import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Coffee import androidx.compose.material.icons.filled.Menu import androidx.compose.material.icons.filled.Search import androidx.compose.material3.ButtonDefaults -import androidx.compose.material3.Card import androidx.compose.material3.DrawerValue import androidx.compose.material3.FilledTonalButton import androidx.compose.material3.ExperimentalMaterial3Api @@ -32,7 +27,6 @@ import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.ModalNavigationDrawer import androidx.compose.material3.Scaffold -import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.material3.TopAppBar import androidx.compose.material3.TopAppBarDefaults @@ -46,8 +40,6 @@ import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.alpha -import androidx.compose.ui.draw.clip import androidx.compose.ui.input.nestedscroll.nestedScroll import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextAlign @@ -59,8 +51,6 @@ import de.jeanlucmakiola.calendula.data.prefs.PastEventDisplay import de.jeanlucmakiola.calendula.domain.EventInstance import de.jeanlucmakiola.calendula.domain.hasEnded import de.jeanlucmakiola.calendula.ui.common.AgendaRangePicker -import de.jeanlucmakiola.calendula.ui.common.LocalSoftenColors -import de.jeanlucmakiola.calendula.ui.common.eventFill import de.jeanlucmakiola.calendula.ui.common.agendaRangeLabel import de.jeanlucmakiola.floret.identity.animateItemMotion import de.jeanlucmakiola.calendula.ui.common.CalendarDrawer @@ -69,25 +59,15 @@ import de.jeanlucmakiola.calendula.ui.common.TodayAction import de.jeanlucmakiola.calendula.ui.common.CalendarFailure import de.jeanlucmakiola.calendula.ui.common.CalendarView import de.jeanlucmakiola.calendula.ui.common.IMPLEMENTED_VIEWS -import de.jeanlucmakiola.floret.locale.localizedDateFormatter -import de.jeanlucmakiola.calendula.ui.common.EventDimAlpha -import de.jeanlucmakiola.floret.components.GroupedRow -import de.jeanlucmakiola.floret.components.Position import de.jeanlucmakiola.calendula.ui.common.ViewSwitcherPill import de.jeanlucmakiola.calendula.ui.common.next import de.jeanlucmakiola.floret.components.positionOf import de.jeanlucmakiola.calendula.ui.common.rememberCurrentMinute import de.jeanlucmakiola.floret.locale.currentLocale -import de.jeanlucmakiola.calendula.ui.common.LocalUse24HourFormat -import de.jeanlucmakiola.calendula.ui.common.formatTimeOfDay import kotlinx.coroutines.launch -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.util.Locale // No file-level zone constant here on purpose: it would be fixed for the process // lifetime and drift from the zone AgendaViewModel groups in after a device @@ -370,7 +350,10 @@ private fun AgendaList( if (day.events.isEmpty()) { // An anchored, event-less today (#35) — "nothing left today". item(key = "placeholder-${day.date}") { - AgendaEmptyDayRow(onClick = { onOpenDay(day.date) }) + AgendaEmptyDayRow( + text = stringResource(R.string.agenda_no_more_today), + onClick = { onOpenDay(day.date) }, + ) } } else { itemsIndexed( @@ -396,100 +379,6 @@ private fun AgendaList( } } -@Composable -private fun AgendaDayHeader( - date: LocalDate, - today: LocalDate, - onOpenDay: (LocalDate) -> Unit, -) { - Surface( - color = MaterialTheme.colorScheme.surface, - modifier = Modifier - .fillMaxWidth() - .clickable { onOpenDay(date) }, - ) { - Text( - text = agendaDayLabel(date, today), - style = MaterialTheme.typography.titleSmall, - color = if (date == today) { - MaterialTheme.colorScheme.primary - } else { - MaterialTheme.colorScheme.onSurfaceVariant - }, - modifier = Modifier.padding(start = 28.dp, end = 28.dp, top = 16.dp, bottom = 8.dp), - ) - } -} - -/** - * A card under an anchored, event-less today (#35) — the same coffee-cup motif - * as the full-screen [AgendaEmpty] state, boxed into a card so today keeps a - * visible slot when nothing is left rather than a bare header. - */ -@Composable -private fun AgendaEmptyDayRow(onClick: () -> Unit) { - Card( - // Match a single event row's resting corner radius (floret groupedShape). - shape = RoundedCornerShape(22.dp), - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 16.dp, vertical = 4.dp) - .clickable(onClick = onClick), - ) { - Column( - modifier = Modifier - .fillMaxWidth() - .padding(vertical = 18.dp, horizontal = 16.dp), - horizontalAlignment = Alignment.CenterHorizontally, - ) { - Icon( - imageVector = Icons.Filled.Coffee, - contentDescription = null, - tint = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.size(36.dp), - ) - Spacer(Modifier.height(8.dp)) - Text( - text = stringResource(R.string.agenda_no_more_today), - style = MaterialTheme.typography.bodyLarge, - color = MaterialTheme.colorScheme.onSurfaceVariant, - textAlign = TextAlign.Center, - ) - } - } -} - -@Composable -private fun AgendaEventRow( - event: EventInstance, - day: LocalDate, - zone: TimeZone, - position: Position, - dimmed: Boolean, - modifier: Modifier = Modifier, - onClick: () -> Unit, -) { - val dark = isSystemInDarkTheme() - val soften = LocalSoftenColors.current - val title = event.title.ifBlank { stringResource(R.string.event_untitled) } - GroupedRow( - modifier = if (dimmed) modifier.alpha(EventDimAlpha) else modifier, - title = title, - summary = agendaTimeSummary(event, day, zone), - position = position, - minHeight = 64.dp, - leading = { - Box( - modifier = Modifier - .size(width = 6.dp, height = 36.dp) - .clip(RoundedCornerShape(3.dp)) - .background(eventFill(event.color, dark, soften)), - ) - }, - onClick = onClick, - ) -} - @Composable private fun AgendaEmpty(modifier: Modifier = Modifier) { Column( @@ -559,64 +448,3 @@ private fun AgendaTopBar( scrollBehavior = scrollBehavior, ) } - -/** "Today · Wed, 17. Jun 2026" — relative word for today/tomorrow, else the date. */ -@Composable -private fun agendaDayLabel(date: LocalDate, today: LocalDate): String { - val relative = when (date) { - today -> stringResource(R.string.agenda_header_today) - today.plus(1, DateTimeUnit.DAY) -> stringResource(R.string.agenda_header_tomorrow) - else -> null - } - val formatted = formatAgendaDate(date) - return if (relative != null) "$relative · $formatted" else formatted -} - -/** - * Time line under the title: "09:00 – 10:00 · Location", "All day", etc. - * - * A multi-day event shows only the part relevant to [day], spelled out so each - * day reads on its own: its first day names the start ("Starts 14:00"), its last - * day the end ("Ends 10:00"), and any whole day in between reads as "All day". - * An all-day multi-day event is simply "All day" on every day it covers. - */ -@Composable -private fun agendaTimeSummary(event: EventInstance, day: LocalDate, zone: TimeZone): String { - val is24Hour = LocalUse24HourFormat.current - val locale = currentLocale() - - val time = when (val label = agendaTimeLabel(event, day, zone)) { - AgendaTimeLabel.AllDay -> stringResource(R.string.event_detail_all_day) - is AgendaTimeLabel.Starts -> stringResource( - R.string.agenda_span_starts, - formatTime(label.start, zone, is24Hour, locale), - ) - is AgendaTimeLabel.Ends -> stringResource( - R.string.agenda_span_ends, - formatTime(label.end, zone, is24Hour, locale), - ) - is AgendaTimeLabel.Range -> "${formatTime(label.start, zone, is24Hour, locale)} – " + - formatTime(label.end, zone, is24Hour, locale) - } - - val location = event.location?.takeIf { it.isNotBlank() } - return if (location != null) "$time · $location" else time -} - -private fun formatTime( - instant: Instant, - zone: TimeZone, - is24Hour: Boolean, - locale: Locale, -): String { - val t = instant.toLocalDateTime(zone).time - return formatTimeOfDay(t.hour, t.minute, is24Hour, locale) -} - -private fun formatAgendaDate(date: LocalDate): String { - val locale = Locale.getDefault() - val java = java.time.LocalDate.of(date.year, date.month.ordinal + 1, date.day) - // Weekday + date in the locale's own field order (e.g. "Wed, Jun 17, 2026" - // vs "Mi., 17. Juni 2026") rather than a hardcoded day-month-year layout. - return localizedDateFormatter(locale, "EEEdMMMy").format(java) -} diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthUiState.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthUiState.kt index 0822035..24f950c 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthUiState.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthUiState.kt @@ -40,9 +40,17 @@ data class MonthWeek( sealed interface MonthUiState { data object Loading : MonthUiState data class Failure(val reason: FailureReason) : MonthUiState + + /** + * [weeks] is what the grid draws; [instancesByDay] is the same events keyed by + * date and *uncapped*, so the split style's day pane can list everything on a + * date without a second provider query — the month grid range already covers + * it. All-day events sort first, then by start. + */ data class Success( val month: YearMonth, val today: LocalDate, val weeks: List, + val instancesByDay: Map> = emptyMap(), ) : MonthUiState } 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 8abd2f0..87a3f19 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 @@ -117,10 +117,12 @@ class MonthViewModel @Inject constructor( if (calendars.isEmpty()) { return MonthUiState.Failure(FailureReason.NoCalendarsConfigured) } + val weeks = layoutMonthWeeks(ym, weekStart, instances, zone) return MonthUiState.Success( month = ym, today = todayDate, - weeks = layoutMonthWeeks(ym, weekStart, instances, zone), + weeks = weeks, + instancesByDay = instancesByDay(weeks.flatMap { it.days }, instances, zone), ) } } @@ -149,31 +151,63 @@ internal fun layoutMonthWeeks( 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) } }, - ) + layoutCalendarWeek(days, instances, zone) } } +/** + * Resolve one week row's events for rendering. Split out of [layoutMonthWeeks] so + * the continuous style — which streams weeks rather than months and so has no + * enclosing [YearMonth] to slice by — lays each row out identically. + * + * [days] must be the row's seven consecutive dates, in display order. + */ +internal fun layoutCalendarWeek( + days: List, + instances: List, + zone: TimeZone, +): MonthWeek { + 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), + ) + } + return 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) } }, + ) +} + +/** + * Every event touching each of [days], all-day first then by start time. Unlike + * [MonthWeek.timedByDay] this keeps multi-day and all-day events on every date + * they cover and applies no display cap, so the split style's day pane can list a + * date in full without querying the provider again. + */ +internal fun instancesByDay( + days: List, + instances: List, + zone: TimeZone, +): Map> = + days.associateWith { day -> + instances + .filter { it.coversDay(day, zone) } + .sortedWith(compareByDescending { it.isAllDay }.thenBy { it.start }) + } + /** * The on-screen grid spans 6 weeks anchored on [weekStart]. Includes the * trailing days of the previous month and the leading days of the next month. diff --git a/app/src/test/java/de/jeanlucmakiola/calendula/ui/month/MonthLayoutTest.kt b/app/src/test/java/de/jeanlucmakiola/calendula/ui/month/MonthLayoutTest.kt new file mode 100644 index 0000000..52e43f8 --- /dev/null +++ b/app/src/test/java/de/jeanlucmakiola/calendula/ui/month/MonthLayoutTest.kt @@ -0,0 +1,220 @@ +package de.jeanlucmakiola.calendula.ui.month + +import com.google.common.truth.Truth.assertThat +import de.jeanlucmakiola.calendula.domain.EventInstance +import kotlinx.datetime.DateTimeUnit +import kotlinx.datetime.DayOfWeek +import kotlinx.datetime.LocalDate +import kotlinx.datetime.TimeZone +import kotlinx.datetime.YearMonth +import kotlinx.datetime.atTime +import kotlinx.datetime.plus +import kotlinx.datetime.toInstant +import kotlin.time.Instant +import org.junit.jupiter.api.Test + +class MonthLayoutTest { + + private val zone = TimeZone.UTC + + // 2026-06-01 is a Monday, so a Monday-anchored June grid starts on the 1st. + private val jun = YearMonth(2026, kotlinx.datetime.Month.JUNE) + private val jun1 = LocalDate(2026, 6, 1) + private val jun8 = LocalDate(2026, 6, 8) + private val weekOf8th = (0..6).map { jun8.plus(it, DateTimeUnit.DAY) } + + private fun at(date: LocalDate, h: Int, m: Int = 0): Instant = + date.atTime(h, m).toInstant(zone) + + private fun timed( + date: LocalDate, + startHour: Int, + endHour: Int, + id: Long = 1L, + title: String = "E", + ) = EventInstance( + instanceId = id, + eventId = id, + calendarId = 1L, + title = title, + start = at(date, startHour), + end = at(date, endHour), + isAllDay = false, + color = 0xFF112233.toInt(), + location = null, + ) + + /** All-day events live at UTC midnights with an *exclusive* end. */ + private fun allDay( + from: LocalDate, + toInclusive: LocalDate = from, + id: Long = 100L, + title: String = "A", + ) = EventInstance( + instanceId = id, + eventId = id, + calendarId = 1L, + title = title, + start = from.atTime(0, 0).toInstant(TimeZone.UTC), + end = toInclusive.plus(1, DateTimeUnit.DAY).atTime(0, 0).toInstant(TimeZone.UTC), + isAllDay = true, + color = 0xFF445566.toInt(), + location = null, + ) + + @Test + fun `startOfGridWeek snaps back to the configured week start`() { + val wed = LocalDate(2026, 6, 10) + assertThat(wed.startOfGridWeek(DayOfWeek.MONDAY)).isEqualTo(jun8) + assertThat(jun8.startOfGridWeek(DayOfWeek.MONDAY)).isEqualTo(jun8) + // A Sunday-anchored week containing the 10th starts on the 7th. + assertThat(wed.startOfGridWeek(DayOfWeek.SUNDAY)).isEqualTo(LocalDate(2026, 6, 7)) + } + + @Test + fun `monthGridRange always covers 42 days from the grid start`() { + val range = monthGridRange(jun, DayOfWeek.MONDAY, zone) + assertThat(range.start).isEqualTo(at(jun1, 0)) + // 42 days inclusive → the last second of 2026-07-12. + assertThat(range.endInclusive) + .isEqualTo(LocalDate(2026, 7, 12).atTime(23, 59, 59).toInstant(zone)) + } + + @Test + fun `week count follows the month's shape rather than a fixed six rows`() { + // June 2026: starts Monday, 30 days → 5 rows. + assertThat(layoutMonthWeeks(jun, DayOfWeek.MONDAY, emptyList(), zone)).hasSize(5) + // August 2026: starts Saturday, 31 days → spills to 6 rows. + val aug = YearMonth(2026, kotlinx.datetime.Month.AUGUST) + assertThat(layoutMonthWeeks(aug, DayOfWeek.MONDAY, emptyList(), zone)).hasSize(6) + // February 2021: starts Monday, 28 days → exactly 4 rows. + val feb = YearMonth(2021, kotlinx.datetime.Month.FEBRUARY) + assertThat(layoutMonthWeeks(feb, DayOfWeek.MONDAY, emptyList(), zone)).hasSize(4) + } + + @Test + fun `layoutMonthWeeks rows are contiguous and seven days wide`() { + val weeks = layoutMonthWeeks(jun, DayOfWeek.MONDAY, emptyList(), zone) + assertThat(weeks.first().days.first()).isEqualTo(jun1) + weeks.forEach { assertThat(it.days).hasSize(7) } + val allDays = weeks.flatMap { it.days } + allDays.zipWithNext { a, b -> assertThat(b).isEqualTo(a.plus(1, DateTimeUnit.DAY)) } + } + + @Test + fun `a multi-day event becomes one span across its columns`() { + val ev = allDay(LocalDate(2026, 6, 10), LocalDate(2026, 6, 12)) + val week = layoutCalendarWeek(weekOf8th, listOf(ev), zone) + + assertThat(week.spans).hasSize(1) + val span = week.spans.single() + assertThat(span.startCol).isEqualTo(2) // Wednesday the 10th + assertThat(span.endCol).isEqualTo(4) // Friday the 12th + assertThat(span.lane).isEqualTo(0) + assertThat(span.continuesLeft).isFalse() + assertThat(span.continuesRight).isFalse() + } + + @Test + fun `a span running past the row end is flagged as continuing`() { + // Saturday the 13th through Tuesday the 16th straddles the row boundary. + val ev = allDay(LocalDate(2026, 6, 13), LocalDate(2026, 6, 16)) + val week = layoutCalendarWeek(weekOf8th, listOf(ev), zone) + + val span = week.spans.single() + assertThat(span.startCol).isEqualTo(5) + assertThat(span.endCol).isEqualTo(6) + assertThat(span.continuesLeft).isFalse() + assertThat(span.continuesRight).isTrue() + + // The following row picks it up with the flags mirrored. + val nextRow = layoutCalendarWeek( + weekOf8th.map { it.plus(7, DateTimeUnit.DAY) }, + listOf(ev), + zone, + ) + val tail = nextRow.spans.single() + assertThat(tail.startCol).isEqualTo(0) + assertThat(tail.endCol).isEqualTo(1) + assertThat(tail.continuesLeft).isTrue() + assertThat(tail.continuesRight).isFalse() + } + + @Test + fun `overlapping spans are stacked on separate lanes`() { + val a = allDay(LocalDate(2026, 6, 9), LocalDate(2026, 6, 11), id = 1L) + val b = allDay(LocalDate(2026, 6, 10), LocalDate(2026, 6, 12), id = 2L) + val week = layoutCalendarWeek(weekOf8th, listOf(a, b), zone) + + assertThat(week.spans.map { it.lane }).containsExactly(0, 1) + } + + @Test + fun `single-day timed events stay pills and sort by start`() { + val late = timed(LocalDate(2026, 6, 10), 14, 15, id = 1L) + val early = timed(LocalDate(2026, 6, 10), 9, 10, id = 2L) + val week = layoutCalendarWeek(weekOf8th, listOf(late, early), zone) + + assertThat(week.spans).isEmpty() + assertThat(week.timedByDay[LocalDate(2026, 6, 10)]?.map { it.instanceId }) + .containsExactly(2L, 1L) + .inOrder() + assertThat(week.timedByDay[LocalDate(2026, 6, 11)]).isEmpty() + } + + @Test + fun `countByDay totals bars and pills on each date`() { + val span = allDay(LocalDate(2026, 6, 10), LocalDate(2026, 6, 12), id = 1L) + val pill = timed(LocalDate(2026, 6, 10), 9, 10, id = 2L) + val week = layoutCalendarWeek(weekOf8th, listOf(span, pill), zone) + + assertThat(week.countByDay[LocalDate(2026, 6, 10)]).isEqualTo(2) + assertThat(week.countByDay[LocalDate(2026, 6, 11)]).isEqualTo(1) + assertThat(week.countByDay[LocalDate(2026, 6, 13)]).isEqualTo(0) + } + + @Test + fun `layoutMonthWeeks agrees with laying each row out on its own`() { + val events = listOf( + allDay(LocalDate(2026, 6, 10), LocalDate(2026, 6, 12), id = 1L), + timed(LocalDate(2026, 6, 18), 9, 10, id = 2L), + ) + val weeks = layoutMonthWeeks(jun, DayOfWeek.MONDAY, events, zone) + + weeks.forEach { row -> + assertThat(row).isEqualTo(layoutCalendarWeek(row.days, events, zone)) + } + } + + @Test + fun `instancesByDay puts all-day events first then orders by start`() { + val allDayEv = allDay(LocalDate(2026, 6, 10), id = 1L) + val late = timed(LocalDate(2026, 6, 10), 14, 15, id = 2L) + val early = timed(LocalDate(2026, 6, 10), 9, 10, id = 3L) + + val byDay = instancesByDay(weekOf8th, listOf(late, early, allDayEv), zone) + + assertThat(byDay.getValue(LocalDate(2026, 6, 10)).map { it.instanceId }) + .containsExactly(1L, 3L, 2L) + .inOrder() + } + + @Test + fun `instancesByDay repeats a multi-day event on every date it covers`() { + val ev = allDay(LocalDate(2026, 6, 10), LocalDate(2026, 6, 12)) + val byDay = instancesByDay(weekOf8th, listOf(ev), zone) + + assertThat(byDay.getValue(LocalDate(2026, 6, 9))).isEmpty() + assertThat(byDay.getValue(LocalDate(2026, 6, 10))).hasSize(1) + assertThat(byDay.getValue(LocalDate(2026, 6, 11))).hasSize(1) + assertThat(byDay.getValue(LocalDate(2026, 6, 12))).hasSize(1) + assertThat(byDay.getValue(LocalDate(2026, 6, 13))).isEmpty() + } + + @Test + fun `instancesByDay covers every date in the grid, empty ones included`() { + val byDay = instancesByDay(weekOf8th, emptyList(), zone) + assertThat(byDay.keys).containsExactlyElementsIn(weekOf8th) + assertThat(byDay.values.flatten()).isEmpty() + } +} From 7510e1f9af39b74e4aa5f309a63f6669188b756f Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Mon, 20 Jul 2026 16:45:42 +0200 Subject: [PATCH 38/78] feat(settings): add the month view style picker (#38, #53) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduces MonthViewStyle{Paged, Continuous, Split} and the setting that chooses it. Only Paged is wired up so far — the other two land next; this commit is the pref, the state plumbing and the chooser. One picker rather than two switches ("vertical scrolling" + "month with agenda"): independent toggles would multiply into four combinations, most of which nobody asked for. Split does not disable the Agenda view — that stays a forward multi-day window, while the split pane lists one selected day. The chooser lives on the Views settings screen (per-view layout belongs with the other view configuration) and is a hand-rolled FullScreenPicker rather than OptionPicker, which cannot render previews: the three options differ in shape, which a word like "Continuous" does not convey. Each card carries a schematic drawn from theme tokens, and selection reads three ways over — border weight, container tint and a check — so it never rests on colour alone. The cards are a selectableGroup with Role.RadioButton for screen readers. Folded into the ViewCustomization holder, which had spare arity; the outer settings combine is still at its five-flow limit. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../calendula/data/prefs/SettingsPrefs.kt | 14 + .../calendula/ui/month/MonthViewStyle.kt | 45 +++ .../ui/settings/MonthViewStylePicker.kt | 297 ++++++++++++++++++ .../calendula/ui/settings/SettingsScreen.kt | 21 ++ .../calendula/ui/settings/SettingsUiState.kt | 3 + .../ui/settings/SettingsViewModel.kt | 15 +- app/src/main/res/values/strings.xml | 13 +- .../calendula/data/prefs/SettingsPrefsTest.kt | 25 ++ 8 files changed, 430 insertions(+), 3 deletions(-) create mode 100644 app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthViewStyle.kt create mode 100644 app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/MonthViewStylePicker.kt diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/data/prefs/SettingsPrefs.kt b/app/src/main/java/de/jeanlucmakiola/calendula/data/prefs/SettingsPrefs.kt index 316e33d..5020eb5 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/data/prefs/SettingsPrefs.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/data/prefs/SettingsPrefs.kt @@ -21,6 +21,7 @@ import de.jeanlucmakiola.calendula.ui.agenda.storageValue import de.jeanlucmakiola.calendula.ui.common.CalendarView import de.jeanlucmakiola.calendula.ui.common.IMPLEMENTED_VIEWS import de.jeanlucmakiola.calendula.ui.common.QuickSwitchConfig +import de.jeanlucmakiola.calendula.ui.month.MonthViewStyle import de.jeanlucmakiola.calendula.ui.theme.FONT_SYSTEM_TOKEN import java.time.ZoneId import kotlinx.coroutines.CoroutineScope @@ -256,6 +257,18 @@ class SettingsPrefs @Inject constructor( store.edit { it[SHOW_WEEK_NUMBERS_KEY] = enabled } } + /** + * How the Month view lays itself out (#38, #53). Defaults to [MonthViewStyle.Paged] + * — the historical behaviour, so existing installs see no change until they opt in. + */ + val monthViewStyle: Flow = store.data.map { prefs -> + prefs[MONTH_VIEW_STYLE_KEY].toEnum(MonthViewStyle.Paged) + } + + suspend fun setMonthViewStyle(style: MonthViewStyle) { + store.edit { it[MONTH_VIEW_STYLE_KEY] = style.name } + } + /** * Where the jump-to-today control lives (issue #60). Default OFF — the * historical layout, where it's an extended FAB that fades in above the "+" @@ -775,6 +788,7 @@ class SettingsPrefs @Inject constructor( internal val PAST_EVENT_DISPLAY_KEY = stringPreferencesKey("agenda_past_event_display") internal val DIM_COMPLETED_EVENTS_KEY = booleanPreferencesKey("dim_completed_events") internal val SHOW_WEEK_NUMBERS_KEY = booleanPreferencesKey("show_week_numbers") + internal val MONTH_VIEW_STYLE_KEY = stringPreferencesKey("month_view_style") internal val TODAY_BUTTON_IN_TOOLBAR_KEY = booleanPreferencesKey("today_button_in_toolbar") internal val DEFAULT_VIEW_KEY = stringPreferencesKey("default_view") internal val QUICK_SWITCH_VIEWS_KEY = stringPreferencesKey("quick_switch_views") diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthViewStyle.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthViewStyle.kt new file mode 100644 index 0000000..6b636a4 --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthViewStyle.kt @@ -0,0 +1,45 @@ +package de.jeanlucmakiola.calendula.ui.month + +import androidx.annotation.StringRes +import de.jeanlucmakiola.calendula.R + +/** + * How the Month view lays itself out (#38, #53). + * + * One setting rather than two independent toggles: "vertical scrolling" and + * "month plus agenda" would otherwise multiply into four combinations to build + * and test, most of which nobody asked for. + * + * [Split] does **not** replace or disable the Agenda view — that stays a forward + * multi-day window with its own range model, while the split pane lists a single + * selected day. + */ +enum class MonthViewStyle { + /** Month pages, swiped left/right. Full event bars and pills per day. */ + Paged, + + /** + * One uninterrupted vertical stream of weeks. Each date appears exactly once, + * which is the point — paging repeats a boundary week at both ends (#38). + */ + Continuous, + + /** Compact dots-only grid over a list of the selected day's events (#53). */ + Split, +} + +@get:StringRes +val MonthViewStyle.labelRes: Int + get() = when (this) { + MonthViewStyle.Paged -> R.string.month_style_paged + MonthViewStyle.Continuous -> R.string.month_style_continuous + MonthViewStyle.Split -> R.string.month_style_split + } + +@get:StringRes +val MonthViewStyle.descriptionRes: Int + get() = when (this) { + MonthViewStyle.Paged -> R.string.month_style_paged_summary + MonthViewStyle.Continuous -> R.string.month_style_continuous_summary + MonthViewStyle.Split -> R.string.month_style_split_summary + } diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/MonthViewStylePicker.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/MonthViewStylePicker.kt new file mode 100644 index 0000000..ffb7a8e --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/MonthViewStylePicker.kt @@ -0,0 +1,297 @@ +package de.jeanlucmakiola.calendula.ui.settings + +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.selection.selectable +import androidx.compose.foundation.selection.selectableGroup +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Check +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.clipToBounds +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import de.jeanlucmakiola.calendula.R +import de.jeanlucmakiola.calendula.ui.month.MonthViewStyle +import de.jeanlucmakiola.calendula.ui.month.descriptionRes +import de.jeanlucmakiola.calendula.ui.month.labelRes +import de.jeanlucmakiola.floret.components.FullScreenPicker + +/** + * The Month view style chooser (#38, #53). Each option carries a schematic of the + * layout it produces rather than a bare label — the three differ in *shape*, which + * a word like "Continuous" doesn't convey on its own. + * + * Tapping applies immediately and the picker stays open, matching the App name + * picker: the change is worth seeing land before leaving. + */ +@Composable +internal fun MonthViewStylePicker( + selected: MonthViewStyle, + onSelect: (MonthViewStyle) -> Unit, + onDismiss: () -> Unit, +) { + FullScreenPicker( + title = stringResource(R.string.settings_month_view_style), + onDismiss = onDismiss, + predictiveBack = true, + ) { + Text( + text = stringResource(R.string.settings_month_view_style_summary), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 24.dp), + ) + Spacer(Modifier.height(24.dp)) + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp) + .selectableGroup(), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + MonthViewStyle.entries.forEach { style -> + MonthStyleOptionCard( + style = style, + selected = style == selected, + onClick = { onSelect(style) }, + ) + } + } + } +} + +/** + * One selectable style: its schematic, name and a line on what it does. Selection + * reads three ways over — border weight, container tint and a check — so it never + * rests on colour alone. + */ +@Composable +private fun MonthStyleOptionCard( + style: MonthViewStyle, + selected: Boolean, + onClick: () -> Unit, + modifier: Modifier = Modifier, +) { + val shape = RoundedCornerShape(24.dp) + val borderColor = if (selected) { + MaterialTheme.colorScheme.primary + } else { + MaterialTheme.colorScheme.outlineVariant + } + val containerColor = if (selected) { + MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.4f) + } else { + MaterialTheme.colorScheme.surfaceContainerHigh + } + Row( + modifier = modifier + .fillMaxWidth() + .clip(shape) + .background(containerColor) + .border(width = if (selected) 2.dp else 1.dp, color = borderColor, shape = shape) + .selectable(selected = selected, role = Role.RadioButton, onClick = onClick) + .padding(all = 16.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(16.dp), + ) { + MonthStyleSchematic(style) + Column(modifier = Modifier.weight(1f)) { + Text( + text = stringResource(style.labelRes), + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.onSurface, + ) + Text( + text = stringResource(style.descriptionRes), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + // Selection indicator: a filled check when active, an empty ring otherwise. + Box( + modifier = Modifier + .size(24.dp) + .clip(CircleShape) + .background(if (selected) MaterialTheme.colorScheme.primary else Color.Transparent) + .then( + if (selected) { + Modifier + } else { + Modifier.border(1.dp, MaterialTheme.colorScheme.outlineVariant, CircleShape) + }, + ), + contentAlignment = Alignment.Center, + ) { + if (selected) { + Icon( + imageVector = Icons.Filled.Check, + contentDescription = null, + tint = MaterialTheme.colorScheme.onPrimary, + modifier = Modifier.size(16.dp), + ) + } + } + } +} + +private val SCHEMATIC_WIDTH = 64.dp +private val SCHEMATIC_HEIGHT = 60.dp +private val SCHEMATIC_SHAPE = RoundedCornerShape(10.dp) + +/** + * A miniature of the layout, drawn from plain boxes on theme tokens — small + * enough to read as an icon, literal enough that the three are told apart at a + * glance: pages sit side by side, the continuous stream runs off both edges, the + * split style stacks a dotted grid over a list. + */ +@Composable +private fun MonthStyleSchematic(style: MonthViewStyle, modifier: Modifier = Modifier) { + Box( + modifier = modifier + .size(width = SCHEMATIC_WIDTH, height = SCHEMATIC_HEIGHT) + .clip(SCHEMATIC_SHAPE) + .background(MaterialTheme.colorScheme.surfaceContainerLowest) + .clipToBounds(), + contentAlignment = Alignment.Center, + ) { + when (style) { + MonthViewStyle.Paged -> PagedSchematic() + MonthViewStyle.Continuous -> ContinuousSchematic() + MonthViewStyle.Split -> SplitSchematic() + } + } +} + +/** Two pages side by side, the second peeking in — a swipe away. */ +@Composable +private fun PagedSchematic() { + Row( + modifier = Modifier.fillMaxWidth().padding(start = 6.dp), + horizontalArrangement = Arrangement.spacedBy(6.dp), + ) { + SchematicGrid(rows = 4, modifier = Modifier.weight(1f)) + // The next page, cropped by the container — the swipe affordance. + SchematicGrid(rows = 4, modifier = Modifier.width(20.dp), dim = true) + } +} + +/** One column of weeks running off both edges: no page breaks, no repeated days. */ +@Composable +private fun ContinuousSchematic() { + Column( + modifier = Modifier.fillMaxWidth().padding(horizontal = 6.dp), + verticalArrangement = Arrangement.spacedBy(4.dp), + ) { + // Six rows in a 60dp box overflow deliberately: the stream is clipped top + // and bottom rather than fitting neatly, which is the whole idea. + repeat(6) { index -> + SchematicWeekRow(dim = index == 0 || index == 5) + } + } +} + +/** A squished dotted grid over the selected day's list. */ +@Composable +private fun SplitSchematic() { + Column( + modifier = Modifier.fillMaxWidth().padding(6.dp), + verticalArrangement = Arrangement.spacedBy(5.dp), + ) { + SchematicGrid(rows = 3, dotted = true) + // The day pane: two stand-in event rows. + repeat(2) { + Box( + modifier = Modifier + .fillMaxWidth() + .height(6.dp) + .clip(RoundedCornerShape(3.dp)) + .background(MaterialTheme.colorScheme.primary.copy(alpha = 0.55f)), + ) + } + } +} + +/** [rows] week rows of seven cells; [dotted] shrinks the cells to event dots. */ +@Composable +private fun SchematicGrid( + rows: Int, + modifier: Modifier = Modifier, + dim: Boolean = false, + dotted: Boolean = false, +) { + Column( + modifier = modifier, + verticalArrangement = Arrangement.spacedBy(if (dotted) 4.dp else 3.dp), + ) { + repeat(rows) { + if (dotted) SchematicDotRow(dim) else SchematicWeekRow(dim) + } + } +} + +/** A week as seven filled cells. */ +@Composable +private fun SchematicWeekRow(dim: Boolean = false, modifier: Modifier = Modifier) { + val color = MaterialTheme.colorScheme.onSurfaceVariant + .copy(alpha = if (dim) 0.18f else 0.38f) + Row( + modifier = modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(2.dp), + ) { + repeat(7) { + Box( + modifier = Modifier + .weight(1f) + .height(5.dp) + .clip(RoundedCornerShape(1.5.dp)) + .background(color), + ) + } + } +} + +/** A week as seven event dots — the split style's compact grid. */ +@Composable +private fun SchematicDotRow(dim: Boolean = false, modifier: Modifier = Modifier) { + val color = MaterialTheme.colorScheme.onSurfaceVariant + .copy(alpha = if (dim) 0.18f else 0.38f) + Row( + modifier = modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(2.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + repeat(7) { + Box( + modifier = Modifier + .weight(1f) + .height(3.dp) + .clip(CircleShape) + .background(color), + ) + } + } +} diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/SettingsScreen.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/SettingsScreen.kt index f58c743..6e983b9 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/SettingsScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/SettingsScreen.kt @@ -125,6 +125,7 @@ import de.jeanlucmakiola.floret.components.GroupedRow import de.jeanlucmakiola.floret.components.InlineTextField import de.jeanlucmakiola.calendula.ui.common.IMPLEMENTED_VIEWS import de.jeanlucmakiola.calendula.ui.common.QuickSwitchConfig +import de.jeanlucmakiola.calendula.ui.month.labelRes import de.jeanlucmakiola.floret.components.ReorderableColumn import de.jeanlucmakiola.floret.components.ReorderableRowHeight import de.jeanlucmakiola.calendula.ui.common.icon @@ -847,12 +848,24 @@ private fun ViewsScreen( viewModel: SettingsViewModel, onBack: () -> Unit, ) { + var showMonthStyle by remember { mutableStateOf(false) } + CollapsingScaffold( title = stringResource(R.string.settings_section_views), onBack = onBack, ) { val config = state.quickSwitchConfig + // Per-view layout, above the cross-view switcher/order settings below. + SectionHeader(stringResource(R.string.settings_month_header)) + GroupedRow( + title = stringResource(R.string.settings_month_view_style), + summary = stringResource(state.monthViewStyle.labelRes), + position = Position.Alone, + onClick = { showMonthStyle = true }, + ) + + Spacer(Modifier.height(24.dp)) SectionHeader(stringResource(R.string.settings_quick_switch_header)) SettingsHint(stringResource(R.string.settings_quick_switch_hint)) Spacer(Modifier.height(8.dp)) @@ -898,6 +911,14 @@ private fun ViewsScreen( ) } } + + if (showMonthStyle) { + MonthViewStylePicker( + selected = state.monthViewStyle, + onSelect = viewModel::setMonthViewStyle, + onDismiss = { showMonthStyle = false }, + ) + } } /** One reorderable view row: the view's icon and name, an optional [trailing] diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/SettingsUiState.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/SettingsUiState.kt index 4fbb436..8f59b2c 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/SettingsUiState.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/SettingsUiState.kt @@ -13,6 +13,7 @@ import de.jeanlucmakiola.calendula.ui.agenda.AgendaRange import de.jeanlucmakiola.calendula.ui.common.CalendarView import de.jeanlucmakiola.calendula.ui.common.IMPLEMENTED_VIEWS import de.jeanlucmakiola.calendula.ui.common.QuickSwitchConfig +import de.jeanlucmakiola.calendula.ui.month.MonthViewStyle /** * Settings screen state (M4). Persisted preferences are instant to read, so @@ -51,6 +52,8 @@ data class SettingsUiState( val defaultView: CalendarView = CalendarView.Week, /** Which views the top-bar quick-switch button cycles through, and their order (#24). */ val quickSwitchConfig: QuickSwitchConfig = QuickSwitchConfig.Default, + /** How the Month view lays itself out: pages, continuous scroll, or split (#38, #53). */ + val monthViewStyle: MonthViewStyle = MonthViewStyle.Paged, /** Order of the views in the navigation drawer (#24); every view is always listed. */ val drawerViewOrder: List = IMPLEMENTED_VIEWS, /** Optional event-form fields shown by default (rest behind "more fields"). */ diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/SettingsViewModel.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/SettingsViewModel.kt index de3bcbe..a549089 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/SettingsViewModel.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/SettingsViewModel.kt @@ -35,6 +35,7 @@ import de.jeanlucmakiola.calendula.ui.agenda.AgendaRange import de.jeanlucmakiola.calendula.ui.agenda.storageValue import de.jeanlucmakiola.calendula.ui.common.CalendarView import de.jeanlucmakiola.calendula.ui.common.QuickSwitchConfig +import de.jeanlucmakiola.calendula.ui.month.MonthViewStyle import de.jeanlucmakiola.calendula.ui.theme.AppFontSettings import de.jeanlucmakiola.calendula.ui.theme.FONT_CUSTOM_TOKEN import de.jeanlucmakiola.calendula.widget.agenda.AGENDA_PAST_DISPLAY_KEY @@ -150,8 +151,12 @@ class SettingsViewModel @Inject constructor( prefs.dimCompletedEvents, // View customisation (#24) folded into one flow so it fits this // group — the outer combine is already at its five-arg limit. - combine(prefs.quickSwitchConfig, prefs.drawerViewOrder) { quickSwitch, drawer -> - ViewCustomization(quickSwitch, drawer) + combine( + prefs.quickSwitchConfig, + prefs.drawerViewOrder, + prefs.monthViewStyle, + ) { quickSwitch, drawer, monthStyle -> + ViewCustomization(quickSwitch, drawer, monthStyle) }, ) { showRangeBar, autofocus, pastEvents, dimCompleted, viewCustomization -> MiscSettings(showRangeBar, autofocus, pastEvents, dimCompleted, viewCustomization) @@ -173,6 +178,7 @@ class SettingsViewModel @Inject constructor( dimCompletedEvents = misc.dimCompletedEvents, quickSwitchConfig = misc.viewCustomization.quickSwitch, drawerViewOrder = misc.viewCustomization.drawerOrder, + monthViewStyle = misc.viewCustomization.monthViewStyle, allowColorOnUnsupportedCalendars = defaults.allowColor, defaultReminderMinutes = defaults.defaultReminder, defaultAllDayReminderMinutes = defaults.allDayReminder, @@ -286,6 +292,7 @@ class SettingsViewModel @Inject constructor( private data class ViewCustomization( val quickSwitch: QuickSwitchConfig, val drawerOrder: List, + val monthViewStyle: MonthViewStyle, ) /** Contact special-dates sub-page (issue #15); its own flow — the main state is full. */ @@ -562,6 +569,10 @@ class SettingsViewModel @Inject constructor( viewModelScope.launch { prefs.updateQuickSwitch { it.copy(order = order) } } } + fun setMonthViewStyle(style: MonthViewStyle) { + viewModelScope.launch { prefs.setMonthViewStyle(style) } + } + fun setDrawerViewOrder(order: List) { viewModelScope.launch { prefs.setDrawerViewOrder(order) } } diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index c7ff4e1..988f6c7 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -372,6 +372,17 @@ %d days Views + + Month view + Month view style + Choose how the month view is laid out and how you move through it. + Pages + One month at a time. Swipe sideways to change month. + Continuous + Scroll up and down through the weeks. No month is cut off and no day appears twice. + Split + A compact grid with dots for events, and the day you tap listed underneath. + Nothing scheduled Quick-switch button Choose which views the top-right button cycles through, and drag to reorder them. Turned-off views stay reachable from the navigation menu. Navigation menu @@ -411,7 +422,7 @@ Add or improve a language on Weblate Theme, default view, week start - Quick-switch button and menu order + Month layout, quick-switch button, menu order Default fields for new events Event reminders Contact birthdays & anniversaries diff --git a/app/src/test/java/de/jeanlucmakiola/calendula/data/prefs/SettingsPrefsTest.kt b/app/src/test/java/de/jeanlucmakiola/calendula/data/prefs/SettingsPrefsTest.kt index 5b53234..7693801 100644 --- a/app/src/test/java/de/jeanlucmakiola/calendula/data/prefs/SettingsPrefsTest.kt +++ b/app/src/test/java/de/jeanlucmakiola/calendula/data/prefs/SettingsPrefsTest.kt @@ -13,6 +13,7 @@ import de.jeanlucmakiola.calendula.ui.agenda.AgendaRange import de.jeanlucmakiola.calendula.ui.common.CalendarView import de.jeanlucmakiola.calendula.ui.common.IMPLEMENTED_VIEWS import de.jeanlucmakiola.calendula.ui.common.QuickSwitchConfig +import de.jeanlucmakiola.calendula.ui.month.MonthViewStyle import kotlinx.coroutines.flow.first import kotlinx.coroutines.test.runTest import kotlinx.datetime.DayOfWeek @@ -125,6 +126,30 @@ class SettingsPrefsTest { assertThat(prefs.agendaWidgetRange.first()).isEqualTo(AgendaRange.Week) } + @Test + fun `month view style defaults to paged and round-trips`(@TempDir tempDir: Path) = runTest { + val prefs = SettingsPrefs(newDataStore(tempDir)) + assertThat(prefs.monthViewStyle.first()).isEqualTo(MonthViewStyle.Paged) + + prefs.setMonthViewStyle(MonthViewStyle.Split) + assertThat(prefs.monthViewStyle.first()).isEqualTo(MonthViewStyle.Split) + + prefs.setMonthViewStyle(MonthViewStyle.Continuous) + assertThat(prefs.monthViewStyle.first()).isEqualTo(MonthViewStyle.Continuous) + } + + @Test + fun `garbage stored month view style falls back to paged`(@TempDir tempDir: Path) = runTest { + val store = newDataStore(tempDir) + val prefs = SettingsPrefs(store) + store.updateData { p -> + val m = p.toMutablePreferences() + m[SettingsPrefs.MONTH_VIEW_STYLE_KEY] = "Carousel" + m + } + assertThat(prefs.monthViewStyle.first()).isEqualTo(MonthViewStyle.Paged) + } + @Test fun `garbage stored enum falls back to default`(@TempDir tempDir: Path) = runTest { val store = newDataStore(tempDir) From 542744e342f4a2e58229ad687a0351e70a0ffc59 Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Mon, 20 Jul 2026 16:54:41 +0200 Subject: [PATCH 39/78] feat(month): continuous vertical scrolling (#38) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds MonthViewStyle.Continuous: one uninterrupted vertical stream of weeks instead of month pages. The layout unit is the ISO week, not the month. A vertical stack of month grids would still repeat a boundary week at the end of one month and the start of the next, which is precisely the duplication the issue asks to be rid of. Streaming weeks means every date appears exactly once. - Weeks are addressed by an absolute index (epoch 1900, so indices stay non-negative and map 1:1 onto LazyColumn item indices), giving the list one stable, gap-free coordinate space to scroll and key by. - The view model loads a sliding window of weeks around the visible range. nextLoadWindow() holds the hysteresis: the window only moves once the visible range comes within four weeks of a loaded edge, so scrolling re-queries the provider occasionally rather than every frame. Unloaded rows render a same-height skeleton, so nothing jumps when the window catches up. - No dimmed "other month" days — every day in the stream belongs to a month equally. Instead the 1st names its month, which is the only marker needed to tell one month from the next, and the top bar title tracks the month the viewport mostly sits in. - The horizontal swipe detector and the paged AnimatedContent are both off in this style; vertical scrolling owns the gesture. Today and drawer jump-to-date animate the list instead of swapping months. The view opens positioned on the current month. MonthWeekRow now takes inMonth as a predicate rather than a YearMonth, which is what lets the same row serve a stream that has no enclosing month. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../calendula/ui/month/MonthScreen.kt | 276 +++++++++++++++--- .../calendula/ui/month/MonthUiState.kt | 18 ++ .../calendula/ui/month/MonthViewModel.kt | 122 ++++++++ .../ui/month/ContinuousWeekIndexTest.kt | 104 +++++++ 4 files changed, 485 insertions(+), 35 deletions(-) create mode 100644 app/src/test/java/de/jeanlucmakiola/calendula/ui/month/ContinuousWeekIndexTest.kt diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthScreen.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthScreen.kt index 7f501dc..b5ddc8e 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthScreen.kt @@ -9,6 +9,7 @@ import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxHeight @@ -19,6 +20,10 @@ import androidx.compose.foundation.layout.offset import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyListState +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons @@ -36,9 +41,12 @@ import androidx.compose.material3.TopAppBar import androidx.compose.material3.TopAppBarDefaults import androidx.compose.material3.rememberDrawerState import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue +import androidx.compose.runtime.key +import androidx.compose.runtime.snapshotFlow import androidx.compose.runtime.mutableFloatStateOf import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.remember @@ -85,11 +93,14 @@ import de.jeanlucmakiola.floret.locale.currentLocale import de.jeanlucmakiola.calendula.ui.common.rememberCalendarSlideSpec import de.jeanlucmakiola.calendula.ui.common.next import de.jeanlucmakiola.floret.time.isoWeekNumber +import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.launch +import kotlinx.datetime.DateTimeUnit import kotlinx.datetime.DayOfWeek import kotlinx.datetime.LocalDate import kotlinx.datetime.TimeZone import kotlinx.datetime.YearMonth +import kotlinx.datetime.plus import kotlinx.datetime.toJavaLocalDate import kotlinx.datetime.toLocalDateTime import kotlin.time.Clock @@ -112,8 +123,10 @@ fun MonthScreen( viewModel: MonthViewModel = hiltViewModel(), ) { val state by viewModel.state.collectAsStateWithLifecycle() + val continuousState by viewModel.continuousState.collectAsStateWithLifecycle() val month by viewModel.month.collectAsStateWithLifecycle() val weekStart by viewModel.weekStart.collectAsStateWithLifecycle() + val viewStyle by viewModel.viewStyle.collectAsStateWithLifecycle() val dimCompleted by viewModel.dimCompletedEvents.collectAsStateWithLifecycle() val showWeekNumbers by viewModel.showWeekNumbers.collectAsStateWithLifecycle() // The instant before which an event counts as completed, or null when dimming @@ -128,17 +141,52 @@ fun MonthScreen( val drawerState = rememberDrawerState(DrawerValue.Closed) val scope = rememberCoroutineScope() - val isOnCurrentMonth = when (val s = state) { - is MonthUiState.Success -> s.month == YearMonth(s.today.year, s.today.month) - else -> true + val continuous = viewStyle == MonthViewStyle.Continuous + + // Today, from whichever state is driving; the clock only covers the first + // frame, before either has loaded. + val today = when { + continuous -> (continuousState as? ContinuousMonthUiState.Success)?.today + else -> (state as? MonthUiState.Success)?.today + } ?: Clock.System.now().toLocalDateTime(TimeZone.currentSystemDefault()).date + + // Keyed on the week start: week indices are anchored on it, so a changed + // week start would leave the saved scroll offset pointing at a different week. + val listState = key(weekStart) { + rememberLazyListState( + initialFirstVisibleItemIndex = + weekIndexOf(LocalDate(month.year, month.month, 1), weekStart), + ) } - // Drives whether the title carries the year. Falls back to the clock only - // while the first load is in flight, when there is no state to read today from. - val currentYear = when (val s = state) { - is MonthUiState.Success -> s.today.year - else -> Clock.System.now().toLocalDateTime(TimeZone.currentSystemDefault()).date.year + // The continuous stream has no single "current" month, so the title takes the + // one the viewport mostly sits in: the midweek day of the row below the top + // edge, which flips over as that month's first full week comes into view. + val visibleMonth by remember(weekStart) { + derivedStateOf { + val midweek = weekStartForIndex(listState.firstVisibleItemIndex + 1, weekStart) + .plus(3, DateTimeUnit.DAY) + YearMonth(midweek.year, midweek.month) + } } + val titleMonth = if (continuous) visibleMonth else month + + // Feed the visible range back so the sliding data window can follow. The + // view model ignores everything that doesn't near a loaded edge. + LaunchedEffect(listState, continuous) { + if (!continuous) return@LaunchedEffect + snapshotFlow { + val visible = listState.layoutInfo.visibleItemsInfo + (visible.firstOrNull()?.index ?: 0) to (visible.lastOrNull()?.index ?: 0) + } + .distinctUntilChanged() + .collect { (first, last) -> viewModel.onVisibleWeeksChanged(first, last) } + } + + val isOnCurrentMonth = titleMonth == YearMonth(today.year, today.month) + + // Drives whether the title carries the year. + val currentYear = today.year // Slide direction for the grid transition: +1 = next, -1 = prev, 0 = jump (no slide). var slideDir by remember { mutableIntStateOf(0) } @@ -152,19 +200,33 @@ fun MonthScreen( viewModel.goToPrev() } // Slide toward today: viewing the future → today comes in from the left - // (back), viewing the past → from the right (forward). + // (back), viewing the past → from the right (forward). The continuous stream + // scrolls to today's week instead — there is nothing to slide. val jumpToToday = { - slideDir = when (val s = state) { - is MonthUiState.Success -> - if (YearMonth(s.today.year, s.today.month) < s.month) -1 else 1 - else -> 0 + if (continuous) { + scope.launch { listState.animateScrollToItem(weekIndexOf(today, weekStart)) } + Unit + } else { + slideDir = when (val s = state) { + is MonthUiState.Success -> + if (YearMonth(s.today.year, s.today.month) < s.month) -1 else 1 + else -> 0 + } + viewModel.goToToday() } - viewModel.goToToday() } // Drawer jump-to-date: slide from the side the target month lies on. val jumpToDate: (LocalDate) -> Unit = { target -> - slideDir = if (YearMonth(target.year, target.month) < month) -1 else 1 - viewModel.goToDate(target) + if (continuous) { + scope.launch { + listState.animateScrollToItem( + weekIndexOf(LocalDate(target.year, target.month, 1), weekStart), + ) + } + } else { + slideDir = if (YearMonth(target.year, target.month) < month) -1 else 1 + viewModel.goToDate(target) + } } ModalNavigationDrawer( @@ -174,7 +236,7 @@ fun MonthScreen( drawerContent = { CalendarDrawer( currentView = selectedView, - currentDate = LocalDate(month.year, month.month, 1), + currentDate = LocalDate(titleMonth.year, titleMonth.month, 1), viewOrder = drawerViewOrder, onSelectView = { view -> onSelectView(view) @@ -195,7 +257,7 @@ fun MonthScreen( modifier = modifier.nestedScroll(scrollBehavior.nestedScrollConnection), topBar = { MonthTopBar( - month = month, + month = titleMonth, currentYear = currentYear, selectedView = selectedView, onCycleView = { onSelectView(selectedView.next(quickSwitchViews)) }, @@ -213,11 +275,9 @@ fun MonthScreen( onToday = jumpToToday, onCreate = { // Anchor on today when its month is shown, else the 1st. - val today = Clock.System.now() - .toLocalDateTime(TimeZone.currentSystemDefault()).date onCreateEvent( if (isOnCurrentMonth) today - else LocalDate(month.year, month.month, 1), + else LocalDate(titleMonth.year, titleMonth.month, 1), null, ) }, @@ -231,15 +291,25 @@ fun MonthScreen( ) { WeekdayHeader(weekStart = weekStart, showWeekNumbers = showWeekNumbers) CompositionLocalProvider(LocalDimCutoff provides dimCutoff) { - MonthContent( - state = state, - slideDir = slideDir, - showWeekNumbers = showWeekNumbers, - onSwipeNext = goNext, - onSwipePrev = goPrev, - onRetry = jumpToToday, - onOpenDay = onOpenDay, - ) + if (continuous) { + ContinuousMonthContent( + state = continuousState, + listState = listState, + showWeekNumbers = showWeekNumbers, + onRetry = jumpToToday, + onOpenDay = onOpenDay, + ) + } else { + MonthContent( + state = state, + slideDir = slideDir, + showWeekNumbers = showWeekNumbers, + onSwipeNext = goNext, + onSwipePrev = goPrev, + onRetry = jumpToToday, + onOpenDay = onOpenDay, + ) + } } } } @@ -303,6 +373,32 @@ private fun MonthContent( } } +/** + * Continuous style content. No swipe detector and no [AnimatedContent]: vertical + * scrolling owns the gesture, and the list never swaps wholesale — it keeps + * scrolling while the window behind it reloads. + */ +@Composable +private fun ContinuousMonthContent( + state: ContinuousMonthUiState, + listState: LazyListState, + showWeekNumbers: Boolean, + onRetry: () -> Unit, + onOpenDay: (LocalDate) -> Unit, +) { + when (state) { + ContinuousMonthUiState.Loading -> MonthGridLoading() + is ContinuousMonthUiState.Failure -> + CalendarFailure(reason = state.reason, onRetry = onRetry) + is ContinuousMonthUiState.Success -> ContinuousMonthGrid( + state = state, + listState = listState, + showWeekNumbers = showWeekNumbers, + onOpenDay = onOpenDay, + ) + } +} + @OptIn(ExperimentalMaterial3Api::class) @Composable private fun MonthTopBar( @@ -391,6 +487,14 @@ private val CELL_GAP = 2.dp private val CELL_SHAPE = RoundedCornerShape(12.dp) private const val MAX_EVENT_ROWS = 3 +/** + * Row height in the continuous grid. The paged grid divides the viewport between + * however many rows the month has; a scrolling stream has no such bound, so it + * fixes a height that seats the day number plus [MAX_EVENT_ROWS] event rows — + * close to what a five-row month gets on a typical phone. + */ +private val CONTINUOUS_ROW_HEIGHT = 112.dp + @Composable private fun MonthGrid( state: MonthUiState.Success, @@ -406,11 +510,12 @@ private fun MonthGrid( .padding(horizontal = 8.dp, vertical = 4.dp), verticalArrangement = Arrangement.spacedBy(2.dp), ) { + val month = state.month state.weeks.forEach { week -> MonthWeekRow( week = week, today = state.today, - month = state.month, + inMonth = { it.month == month.month && it.year == month.year }, showWeekNumbers = showWeekNumbers, onOpenDay = onOpenDay, modifier = Modifier @@ -421,6 +526,78 @@ private fun MonthGrid( } } +/** + * The continuous grid (#38): one uninterrupted vertical stream of weeks. + * + * The list is indexed by *absolute week number*, so a row's identity never + * changes and there is no page seam to scroll across. Weeks the loaded window + * hasn't reached yet render as a skeleton and pull the window along behind them. + * + * Deliberately not a stack of month grids — that would still repeat a boundary + * week at the end of one month and the start of the next, which is exactly the + * duplication #38 asks to be rid of. + */ +@Composable +private fun ContinuousMonthGrid( + state: ContinuousMonthUiState.Success, + listState: LazyListState, + showWeekNumbers: Boolean, + onOpenDay: (LocalDate) -> Unit, + modifier: Modifier = Modifier, +) { + val weekCount = remember(state.weekStart) { continuousWeekCount(state.weekStart) } + LazyColumn( + state = listState, + modifier = modifier + .fillMaxSize() + .padding(horizontal = 8.dp), + verticalArrangement = Arrangement.spacedBy(2.dp), + // Bottom inset clears the FAB stack so the last row stays tappable. + contentPadding = PaddingValues(top = 4.dp, bottom = 96.dp), + ) { + items(count = weekCount, key = { it }) { index -> + val week = state.weeksByIndex[index] + if (week == null) { + ContinuousWeekPlaceholder() + } else { + MonthWeekRow( + week = week, + today = state.today, + // Every day in the stream belongs to a month equally — there + // is no "other month" to recede here. + inMonth = { true }, + showWeekNumbers = showWeekNumbers, + onOpenDay = onOpenDay, + labelMonthOnFirst = true, + modifier = Modifier + .fillMaxWidth() + .height(CONTINUOUS_ROW_HEIGHT), + ) + } + } + } +} + +/** A week the sliding window hasn't loaded yet — same height, so nothing jumps. */ +@Composable +private fun ContinuousWeekPlaceholder() { + Row( + modifier = Modifier + .fillMaxWidth() + .height(CONTINUOUS_ROW_HEIGHT), + ) { + repeat(7) { + Box( + modifier = Modifier + .weight(1f) + .fillMaxHeight() + .padding(horizontal = CELL_GAP, vertical = 1.dp) + .background(MaterialTheme.colorScheme.surfaceContainerLow, CELL_SHAPE), + ) + } + } +} + /** * One week of the grid. Bars (all-day / multi-day) are positioned absolutely so * a multi-day event is one connected bar across the columns; single-day timed @@ -432,10 +609,11 @@ private fun MonthGrid( private fun MonthWeekRow( week: MonthWeek, today: LocalDate, - month: YearMonth, + inMonth: (LocalDate) -> Boolean, showWeekNumbers: Boolean, onOpenDay: (LocalDate) -> Unit, modifier: Modifier = Modifier, + labelMonthOnFirst: Boolean = false, ) { val dark = isSystemInDarkTheme() val laneCount = (week.spans.maxOfOrNull { it.lane } ?: -1) + 1 @@ -466,14 +644,13 @@ private fun MonthWeekRow( // as one continuous event. Row(Modifier.matchParentSize()) { week.days.forEach { d -> - val inMonth = d.month == month.month && d.year == month.year Box( Modifier .weight(1f) .fillMaxHeight() .padding(horizontal = CELL_GAP, vertical = 1.dp) .background( - color = if (inMonth) MaterialTheme.colorScheme.surfaceContainer + color = if (inMonth(d)) MaterialTheme.colorScheme.surfaceContainer else MaterialTheme.colorScheme.surfaceContainerLow, shape = CELL_SHAPE, ), @@ -487,7 +664,15 @@ private fun MonthWeekRow( DayNumberCell( date = d, isToday = d == today, - inMonth = d.month == month.month && d.year == month.year, + inMonth = inMonth(d), + // Continuous has no month boundaries to dim across, so + // the 1st names its month instead — the only marker + // telling one month from the next inside the stream. + monthLabel = if (labelMonthOnFirst && d.day == 1) { + shortMonthName(d) + } else { + null + }, modifier = Modifier.weight(1f), ) } @@ -620,6 +805,7 @@ private fun DayNumberCell( isToday: Boolean, inMonth: Boolean, modifier: Modifier = Modifier, + monthLabel: String? = null, ) { Box( modifier = modifier.height(DAY_NUMBER_HEIGHT), @@ -639,6 +825,16 @@ private fun DayNumberCell( color = MaterialTheme.colorScheme.onPrimary, ) } + } else if (monthLabel != null) { + Text( + text = "$monthLabel ${date.day}", + style = MaterialTheme.typography.labelMedium, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.primary, + maxLines = 1, + overflow = TextOverflow.Visible, + softWrap = false, + ) } else { Text( text = date.day.toString(), @@ -650,6 +846,16 @@ private fun DayNumberCell( } } +/** Locale-short month name ("Jul", "Juli"), for the 1st in the continuous grid. */ +@Composable +private fun shortMonthName(date: LocalDate): String { + val locale = currentLocale() + return remember(date.month, locale) { + java.time.Month.of(date.month.ordinal + 1) + .getDisplayName(JavaTextStyle.SHORT, locale) + } +} + /** A filled event pill/bar — softened (or raw) fill, title clipped to one line. */ @Composable private fun MonthBar( diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthUiState.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthUiState.kt index 24f950c..7e1f3f0 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthUiState.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthUiState.kt @@ -2,6 +2,7 @@ package de.jeanlucmakiola.calendula.ui.month import de.jeanlucmakiola.calendula.domain.EventInstance import de.jeanlucmakiola.calendula.domain.FailureReason +import kotlinx.datetime.DayOfWeek import kotlinx.datetime.LocalDate import kotlinx.datetime.YearMonth @@ -37,6 +38,23 @@ data class MonthWeek( val countByDay: Map, ) +/** + * State for the continuous style (#38). Weeks are keyed by absolute week index + * rather than gathered into months: the whole point of the style is that there + * are no month boundaries to scroll across, so there is no "current month" here + * and no notion of an out-of-month day. [weeksByIndex] holds only the loaded + * window; indices outside it render as placeholders until the window catches up. + */ +sealed interface ContinuousMonthUiState { + data object Loading : ContinuousMonthUiState + data class Failure(val reason: FailureReason) : ContinuousMonthUiState + data class Success( + val today: LocalDate, + val weeksByIndex: Map, + val weekStart: DayOfWeek, + ) : ContinuousMonthUiState +} + sealed interface MonthUiState { data object Loading : MonthUiState data class Failure(val reason: FailureReason) : MonthUiState 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 87a3f19..1de6dab 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 @@ -29,6 +29,7 @@ import kotlinx.datetime.TimeZone import kotlinx.datetime.YearMonth import kotlinx.datetime.atStartOfDayIn import kotlinx.datetime.atTime +import kotlinx.datetime.daysUntil import kotlinx.datetime.minus import kotlinx.datetime.plus import kotlinx.datetime.toInstant @@ -66,6 +67,14 @@ class MonthViewModel @Inject constructor( initialValue = false, ) + /** How the grid is laid out and navigated (#38, #53). */ + val viewStyle: StateFlow = settingsPrefs.monthViewStyle + .stateIn( + scope = viewModelScope, + started = SharingStarted.WhileSubscribed(5_000L), + initialValue = MonthViewStyle.Paged, + ) + private val todayDate: LocalDate get() = Clock.System.now().toLocalDateTime(zone).date @@ -91,6 +100,73 @@ class MonthViewModel @Inject constructor( initialValue = MonthUiState.Loading, ) + // --- Continuous style (#38) ------------------------------------------- + // + // The continuous grid is one endless stream of weeks, so it can't load "a + // month" — it loads a sliding window of week indices around whatever is on + // screen. The window only moves when the visible range comes within + // WINDOW_EDGE weeks of a loaded edge, so a scroll re-queries occasionally + // rather than on every frame. + + // Seeded around today so the first frame has data. The week-start preference + // hasn't arrived yet, but anchoring on a different day shifts an index by at + // most one week — well inside the pad, and the first scroll report corrects it. + private val _loadedWeeks = MutableStateFlow( + weekIndexOf(todayDate, DayOfWeek.MONDAY).let { it - WINDOW_PAD..it + WINDOW_PAD }, + ) + + val continuousState: StateFlow = + combine(_loadedWeeks, weekStart) { window, ws -> window to ws } + .flatMapLatest { (window, ws) -> + val first = weekStartForIndex(window.first, ws) + val last = weekStartForIndex(window.last, ws).plus(6, DateTimeUnit.DAY) + val range = first.atStartOfDayIn(zone)..last.atTime(23, 59, 59).toInstant(zone) + combine( + repository.calendars(), + repository.instances(range), + ) { calendars, instances -> + buildContinuousState(window, ws, calendars, instances) + } + } + .catch { emit(ContinuousMonthUiState.Failure(FailureReason.ProviderUnavailable)) } + .flowOn(io) + .stateIn( + scope = viewModelScope, + started = SharingStarted.WhileSubscribed(5_000L), + initialValue = ContinuousMonthUiState.Loading, + ) + + /** + * Report which absolute week indices are on screen. Cheap to call on every + * scroll frame: it returns immediately unless the visible range has drifted + * close enough to a loaded edge to warrant a wider query. + */ + fun onVisibleWeeksChanged(firstIndex: Int, lastIndex: Int) { + nextLoadWindow(_loadedWeeks.value, firstIndex, lastIndex)?.let { _loadedWeeks.value = it } + } + + private fun buildContinuousState( + window: IntRange, + weekStart: DayOfWeek, + calendars: List, + instances: List, + ): ContinuousMonthUiState { + if (calendars.isEmpty()) { + return ContinuousMonthUiState.Failure(FailureReason.NoCalendarsConfigured) + } + val weeks = window.associateWith { index -> + val days = (0 until 7).map { + weekStartForIndex(index, weekStart).plus(it, DateTimeUnit.DAY) + } + layoutCalendarWeek(days, instances, zone) + } + return ContinuousMonthUiState.Success( + today = todayDate, + weeksByIndex = weeks, + weekStart = weekStart, + ) + } + fun goToPrev() { _month.value = _month.value.minus(1, DateTimeUnit.MONTH) } @@ -225,6 +301,52 @@ internal fun monthGridRange( return start..end } +/** + * How many weeks the continuous window loads beyond the visible range, and how + * close the visible range may drift to a loaded edge before it reloads. The pad + * is generous relative to the trigger so a steady scroll crosses the trigger + * well before it would run out of laid-out weeks. + */ +private const val WINDOW_PAD = 12 +private const val WINDOW_EDGE = 4 + +/** + * The window to load for a visible range, or null to keep the current one. + * + * Kept pure and separate from the view model so the hysteresis — the reason a + * scroll doesn't re-query the provider on every frame — is testable on its own. + */ +internal fun nextLoadWindow(loaded: IntRange, firstVisible: Int, lastVisible: Int): IntRange? { + val comfortablyInside = + firstVisible - WINDOW_EDGE >= loaded.first && lastVisible + WINDOW_EDGE <= loaded.last + if (comfortablyInside) return null + return (firstVisible - WINDOW_PAD)..(lastVisible + WINDOW_PAD) +} + +/** + * The continuous grid addresses weeks by an absolute index rather than a + * (month, row) pair, so the list has one stable, gap-free coordinate space to + * scroll through and key its items by. Index 0 is the first week of 1900 under + * the active week-start; the list runs to [CONTINUOUS_WEEK_COUNT]. + * + * The epoch is deliberately far in the past so every index is non-negative, + * which keeps the LazyColumn's item indices and week indices the same number. + */ +private val WEEK_INDEX_EPOCH = LocalDate(1900, 1, 1) +private val WEEK_INDEX_END = LocalDate(2100, 12, 31) + +internal fun weekIndexOf(date: LocalDate, weekStart: DayOfWeek): Int { + val base = WEEK_INDEX_EPOCH.startOfGridWeek(weekStart) + return base.daysUntil(date.startOfGridWeek(weekStart)) / 7 +} + +internal fun weekStartForIndex(index: Int, weekStart: DayOfWeek): LocalDate = + WEEK_INDEX_EPOCH.startOfGridWeek(weekStart).plus(index * 7, DateTimeUnit.DAY) + +/** Total weeks the continuous grid scrolls through (1900 → 2100). */ +internal fun continuousWeekCount(weekStart: DayOfWeek): Int = + weekIndexOf(WEEK_INDEX_END, weekStart) + 1 + internal fun LocalDate.startOfGridWeek(weekStart: DayOfWeek): LocalDate { // DayOfWeek.ordinal: MONDAY=0..SUNDAY=6 → identical to ISO ordering. val offset = ((dayOfWeek.ordinal - weekStart.ordinal) + 7) % 7 diff --git a/app/src/test/java/de/jeanlucmakiola/calendula/ui/month/ContinuousWeekIndexTest.kt b/app/src/test/java/de/jeanlucmakiola/calendula/ui/month/ContinuousWeekIndexTest.kt new file mode 100644 index 0000000..a45a2c3 --- /dev/null +++ b/app/src/test/java/de/jeanlucmakiola/calendula/ui/month/ContinuousWeekIndexTest.kt @@ -0,0 +1,104 @@ +package de.jeanlucmakiola.calendula.ui.month + +import com.google.common.truth.Truth.assertThat +import kotlinx.datetime.DateTimeUnit +import kotlinx.datetime.DayOfWeek +import kotlinx.datetime.LocalDate +import kotlinx.datetime.plus +import org.junit.jupiter.api.Test + +/** + * The continuous grid's coordinate space. Every row's identity — and the + * LazyColumn item it maps to — hangs off this arithmetic, so it gets its own + * tests rather than being exercised only through the UI. + */ +class ContinuousWeekIndexTest { + + // 2026-06-08 is a Monday; 2026-06-10 the Wednesday of the same week. + private val mon = LocalDate(2026, 6, 8) + private val wed = LocalDate(2026, 6, 10) + + @Test + fun `every day of a week shares one index`() { + val indices = (0..6).map { weekIndexOf(mon.plus(it, DateTimeUnit.DAY), DayOfWeek.MONDAY) } + assertThat(indices.toSet()).hasSize(1) + } + + @Test + fun `consecutive weeks are consecutive indices`() { + val a = weekIndexOf(mon, DayOfWeek.MONDAY) + val b = weekIndexOf(mon.plus(7, DateTimeUnit.DAY), DayOfWeek.MONDAY) + val back = weekIndexOf(mon.plus(-7, DateTimeUnit.DAY), DayOfWeek.MONDAY) + assertThat(b).isEqualTo(a + 1) + assertThat(back).isEqualTo(a - 1) + } + + @Test + fun `index round-trips back to the week's first day`() { + DayOfWeek.entries.forEach { ws -> + val index = weekIndexOf(wed, ws) + assertThat(weekStartForIndex(index, ws)).isEqualTo(wed.startOfGridWeek(ws)) + } + } + + @Test + fun `the week start shifts which week a boundary day belongs to`() { + // Sunday the 14th closes the Monday-anchored week but opens the Sunday one. + val sun = LocalDate(2026, 6, 14) + assertThat(weekIndexOf(sun, DayOfWeek.MONDAY)) + .isEqualTo(weekIndexOf(mon, DayOfWeek.MONDAY)) + assertThat(weekIndexOf(sun, DayOfWeek.SUNDAY)) + .isEqualTo(weekIndexOf(sun.plus(1, DateTimeUnit.DAY), DayOfWeek.SUNDAY)) + } + + @Test + fun `indices are non-negative across the supported span`() { + // The epoch sits before any date the grid scrolls to, so item indices and + // week indices stay the same number — no offset to reconcile. + DayOfWeek.entries.forEach { ws -> + assertThat(weekIndexOf(LocalDate(1900, 1, 1), ws)).isAtLeast(0) + assertThat(weekIndexOf(LocalDate(2026, 6, 8), ws)).isGreaterThan(0) + } + } + + @Test + fun `the list spans 1900 through 2100`() { + DayOfWeek.entries.forEach { ws -> + val count = continuousWeekCount(ws) + assertThat(weekIndexOf(LocalDate(2100, 12, 31), ws)).isLessThan(count) + assertThat(weekIndexOf(LocalDate(2026, 6, 8), ws)).isLessThan(count) + // ~200 years of weeks, give or take the anchor. + assertThat(count).isIn(10_400..10_500) + } + } + + @Test + fun `a window comfortably around the visible range is kept`() { + assertThat(nextLoadWindow(loaded = 0..100, firstVisible = 40, lastVisible = 45)).isNull() + } + + @Test + fun `nearing a loaded edge widens the window around the visible range`() { + // Within four weeks of the top edge → reload, padded on both sides. + val widened = nextLoadWindow(loaded = 0..100, firstVisible = 2, lastVisible = 7) + assertThat(widened).isEqualTo(-10..19) + + val atBottom = nextLoadWindow(loaded = 0..100, firstVisible = 94, lastVisible = 99) + assertThat(atBottom).isEqualTo(82..111) + } + + @Test + fun `a jump far outside the window reloads around the destination`() { + assertThat(nextLoadWindow(loaded = 0..100, firstVisible = 500, lastVisible = 505)) + .isEqualTo(488..517) + } + + @Test + fun `the reloaded window always clears the trigger it just crossed`() { + // Otherwise every scroll frame would re-trigger a query. + var loaded = 0..100 + val window = nextLoadWindow(loaded, firstVisible = 2, lastVisible = 7)!! + loaded = window + assertThat(nextLoadWindow(loaded, firstVisible = 2, lastVisible = 7)).isNull() + } +} From becb9a67100a82e010126e607a04c659cae62861 Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Mon, 20 Jul 2026 17:00:22 +0200 Subject: [PATCH 40/78] feat(month): split style with a day pane (#53) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds MonthViewStyle.Split: the month compressed to day numbers and event dots over a list of the selected day's events. - The app's first selected-day concept. Every other view drills straight into a date; here a tap selects, because the pane below is already the answer to "what's on this day" and opening a whole screen would defeat the layout. The full Day view stays one tap away on the pane's date header, matching the Week and Agenda headers (#37). - The pane reuses the agenda's row vocabulary (extracted in the groundwork commit) rather than growing a parallel set, so the two surfaces read as one app. It needs no extra provider query: instancesByDay is already covered by the month grid range. - Selection follows the month — today when the new month holds it, else the 1st — so the pane never lists a day the grid isn't showing. Tapping a leading or trailing day follows it to its own month. - Selection and today are different signals (tinted, outlined cell vs. the filled circle the other views use for today), so both read when they land on the same day. - The swipe stays on the grid alone; the pane scrolls and is full of tappable rows. MonthScreen gains onEventClick, wired in CalendarHost alongside the other three views, and MonthUiState.Success now carries its zone for the same reason the agenda's does. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../calendula/ui/CalendarHost.kt | 1 + .../calendula/ui/month/MonthScreen.kt | 311 +++++++++++++++++- .../calendula/ui/month/MonthUiState.kt | 8 + .../calendula/ui/month/MonthViewModel.kt | 46 +++ .../ui/month/ContinuousWeekIndexTest.kt | 17 + 5 files changed, 365 insertions(+), 18 deletions(-) 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 cf5abea..b830f74 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/CalendarHost.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/CalendarHost.kt @@ -338,6 +338,7 @@ fun CalendarHost( selectedView = currentView, onSelectView = onSelectView, onOpenDay = onOpenDay, + onEventClick = onEventClick, onOpenSettings = onOpenSettings, onOpenSearch = onOpenSearch, onCreateEvent = onCreateEvent, diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthScreen.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthScreen.kt index b5ddc8e..6a5e821 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthScreen.kt @@ -2,6 +2,7 @@ package de.jeanlucmakiola.calendula.ui.month import androidx.compose.animation.AnimatedContent import androidx.compose.foundation.background +import androidx.compose.foundation.border import androidx.compose.foundation.clickable import androidx.compose.foundation.gestures.detectHorizontalDragGestures import androidx.compose.foundation.isSystemInDarkTheme @@ -23,7 +24,9 @@ import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyListState import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.selection.selectable import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons @@ -31,6 +34,7 @@ import androidx.compose.material.icons.filled.Menu import androidx.compose.material.icons.filled.Search import androidx.compose.material3.DrawerValue import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme @@ -71,7 +75,11 @@ import androidx.compose.ui.unit.dp import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle import de.jeanlucmakiola.calendula.R +import de.jeanlucmakiola.calendula.domain.EventInstance import de.jeanlucmakiola.calendula.domain.hasEnded +import de.jeanlucmakiola.calendula.ui.agenda.AgendaDayHeader +import de.jeanlucmakiola.calendula.ui.agenda.AgendaEmptyDayRow +import de.jeanlucmakiola.calendula.ui.agenda.AgendaEventRow import de.jeanlucmakiola.calendula.ui.common.formatCalendarTitle import de.jeanlucmakiola.calendula.ui.common.CalendarDrawer import de.jeanlucmakiola.calendula.ui.common.CalendarFabColumn @@ -88,6 +96,7 @@ import de.jeanlucmakiola.calendula.ui.common.rememberCurrentMinute import de.jeanlucmakiola.calendula.ui.common.ViewSwitcherPill import de.jeanlucmakiola.calendula.ui.common.calendarSlideTransition import de.jeanlucmakiola.calendula.ui.common.rememberCalendarFadeSpec +import de.jeanlucmakiola.floret.components.positionOf import de.jeanlucmakiola.floret.identity.rememberReduceMotion import de.jeanlucmakiola.floret.locale.currentLocale import de.jeanlucmakiola.calendula.ui.common.rememberCalendarSlideSpec @@ -113,6 +122,7 @@ fun MonthScreen( selectedView: CalendarView, onSelectView: (CalendarView) -> Unit, onOpenDay: (LocalDate) -> Unit, + onEventClick: (EventInstance) -> Unit, onOpenSettings: () -> Unit, onOpenSearch: () -> Unit, onCreateEvent: (LocalDate, Int?) -> Unit, @@ -127,6 +137,7 @@ fun MonthScreen( val month by viewModel.month.collectAsStateWithLifecycle() val weekStart by viewModel.weekStart.collectAsStateWithLifecycle() val viewStyle by viewModel.viewStyle.collectAsStateWithLifecycle() + val selectedDate by viewModel.selectedDate.collectAsStateWithLifecycle() val dimCompleted by viewModel.dimCompletedEvents.collectAsStateWithLifecycle() val showWeekNumbers by viewModel.showWeekNumbers.collectAsStateWithLifecycle() // The instant before which an event counts as completed, or null when dimming @@ -299,6 +310,19 @@ fun MonthScreen( onRetry = jumpToToday, onOpenDay = onOpenDay, ) + } else if (viewStyle == MonthViewStyle.Split) { + SplitMonthContent( + state = state, + selected = selectedDate, + showWeekNumbers = showWeekNumbers, + onSwipeNext = goNext, + onSwipePrev = goPrev, + onRetry = jumpToToday, + onSelectDay = viewModel::selectDate, + onOpenDay = onOpenDay, + onEventClick = onEventClick, + onCreateEvent = { onCreateEvent(it, null) }, + ) } else { MonthContent( state = state, @@ -326,27 +350,10 @@ private fun MonthContent( onRetry: () -> Unit, onOpenDay: (LocalDate) -> Unit, ) { - val density = LocalDensity.current - val threshold = with(density) { 6.dp.toPx() } - var dragAccum by remember { mutableFloatStateOf(0f) } val slideSpec = rememberCalendarSlideSpec() val fadeSpec = rememberCalendarFadeSpec() val reduceMotion = rememberReduceMotion() - - val swipeModifier = Modifier.pointerInput(Unit) { - detectHorizontalDragGestures( - onDragStart = { dragAccum = 0f }, - onDragEnd = { - when { - dragAccum < -threshold -> onSwipeNext() - dragAccum > threshold -> onSwipePrev() - } - dragAccum = 0f - }, - onDragCancel = { dragAccum = 0f }, - onHorizontalDrag = { _, drag -> dragAccum += drag }, - ) - } + val swipeModifier = rememberMonthSwipeModifier(onSwipeNext, onSwipePrev) AnimatedContent( targetState = state, @@ -578,6 +585,274 @@ private fun ContinuousMonthGrid( } } +/** + * The month-changing horizontal swipe, shared by the paged and split styles. + * Accumulates the drag and commits past a small threshold on release — the grid + * doesn't follow the finger, so there is no distance to rubber-band against. + */ +@Composable +private fun rememberMonthSwipeModifier( + onSwipeNext: () -> Unit, + onSwipePrev: () -> Unit, +): Modifier { + val threshold = with(LocalDensity.current) { 6.dp.toPx() } + var dragAccum by remember { mutableFloatStateOf(0f) } + return Modifier.pointerInput(Unit) { + detectHorizontalDragGestures( + onDragStart = { dragAccum = 0f }, + onDragEnd = { + when { + dragAccum < -threshold -> onSwipeNext() + dragAccum > threshold -> onSwipePrev() + } + dragAccum = 0f + }, + onDragCancel = { dragAccum = 0f }, + onHorizontalDrag = { _, drag -> dragAccum += drag }, + ) + } +} + +/** + * Split style content: the compact grid keeps the month swipe, the pane below it + * lists whatever day is selected. + * + * No [AnimatedContent] here, unlike the paged style. The grid is 4–6 rows tall + * depending on the month, so sliding one month over another would animate a + * height change under a pane that is trying to hold still. + */ +@Composable +private fun SplitMonthContent( + state: MonthUiState, + selected: LocalDate, + showWeekNumbers: Boolean, + onSwipeNext: () -> Unit, + onSwipePrev: () -> Unit, + onRetry: () -> Unit, + onSelectDay: (LocalDate) -> Unit, + onOpenDay: (LocalDate) -> Unit, + onEventClick: (EventInstance) -> Unit, + onCreateEvent: (LocalDate) -> Unit, +) { + when (state) { + MonthUiState.Loading -> MonthGridLoading() + is MonthUiState.Failure -> CalendarFailure(reason = state.reason, onRetry = onRetry) + is MonthUiState.Success -> Column(Modifier.fillMaxSize()) { + SplitMonthGrid( + state = state, + selected = selected, + showWeekNumbers = showWeekNumbers, + onSelectDay = onSelectDay, + // The swipe lives on the grid alone — the pane scrolls and is + // full of tappable rows, so it shouldn't also be a month gesture. + modifier = rememberMonthSwipeModifier(onSwipeNext, onSwipePrev), + ) + HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant) + SplitDayPane( + date = selected, + today = state.today, + events = state.instancesByDay[selected].orEmpty(), + zone = state.zone, + onOpenDay = onOpenDay, + onEventClick = onEventClick, + onCreateEvent = onCreateEvent, + modifier = Modifier.weight(1f).fillMaxWidth(), + ) + } + } +} + +// --- Split style (#53) ---------------------------------------------------- + +/** + * Row height in the split grid. Only a day number and a row of dots to seat, so + * it's roughly a third of a paged row — which is the point: the space it gives + * up goes to the day pane below. + */ +private val SPLIT_ROW_HEIGHT = 46.dp +private val SPLIT_DOT_SIZE = 5.dp +private const val SPLIT_MAX_DOTS = 3 + +/** + * The split style's grid (#53): the month compressed to day numbers and event + * dots, with the selected day listed underneath by [SplitDayPane]. + * + * Tapping selects rather than drilling into the Day view — the pane is the + * answer to "what's on this day", so opening a whole screen for it would defeat + * the layout. The full Day view stays one tap away on the pane's date header. + */ +@Composable +private fun SplitMonthGrid( + state: MonthUiState.Success, + selected: LocalDate, + showWeekNumbers: Boolean, + onSelectDay: (LocalDate) -> Unit, + modifier: Modifier = Modifier, +) { + val dark = isSystemInDarkTheme() + val month = state.month + Column( + modifier = modifier + .fillMaxWidth() + .padding(horizontal = 8.dp, vertical = 4.dp), + verticalArrangement = Arrangement.spacedBy(2.dp), + ) { + state.weeks.forEach { week -> + Row(Modifier.fillMaxWidth().height(SPLIT_ROW_HEIGHT)) { + if (showWeekNumbers) { + WeekNumberGutter( + weekStart = week.days.first(), + modifier = Modifier + .width(WEEK_NUMBER_GUTTER) + .fillMaxHeight(), + ) + } + week.days.forEach { day -> + SplitDayCell( + date = day, + events = state.instancesByDay[day].orEmpty(), + isToday = day == state.today, + isSelected = day == selected, + inMonth = day.month == month.month && day.year == month.year, + dark = dark, + onClick = { onSelectDay(day) }, + modifier = Modifier.weight(1f).fillMaxHeight(), + ) + } + } + } + } +} + +/** + * One compact day: its number over up to [SPLIT_MAX_DOTS] event dots. + * + * Selection and today are deliberately different signals — a tinted, outlined + * cell versus the filled circle the other views already use for today — so the + * two read at once when they land on the same day. + */ +@Composable +private fun SplitDayCell( + date: LocalDate, + events: List, + isToday: Boolean, + isSelected: Boolean, + inMonth: Boolean, + dark: Boolean, + onClick: () -> Unit, + modifier: Modifier = Modifier, +) { + val background = when { + isSelected -> MaterialTheme.colorScheme.primaryContainer + inMonth -> MaterialTheme.colorScheme.surfaceContainer + else -> MaterialTheme.colorScheme.surfaceContainerLow + } + Box( + modifier = modifier + .padding(horizontal = CELL_GAP, vertical = 1.dp) + .clip(CELL_SHAPE) + .background(background) + .then( + if (isSelected) { + Modifier.border(1.5.dp, MaterialTheme.colorScheme.primary, CELL_SHAPE) + } else { + Modifier + }, + ) + .selectable(selected = isSelected, onClick = onClick), + contentAlignment = Alignment.TopCenter, + ) { + Column( + modifier = Modifier.fillMaxSize().padding(top = 4.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + DayNumberCell( + date = date, + isToday = isToday, + inMonth = inMonth, + modifier = Modifier.fillMaxWidth(), + ) + Spacer(Modifier.height(2.dp)) + SplitDots(events = events, dark = dark) + } + } +} + +/** Up to three colour dots for a day, plus a count when more are hidden. */ +@Composable +private fun SplitDots(events: List, dark: Boolean) { + if (events.isEmpty()) return + val soften = LocalSoftenColors.current + val colors = remember(events) { events.map { it.color }.distinct().take(SPLIT_MAX_DOTS) } + val extra = events.size - colors.size + Row( + horizontalArrangement = Arrangement.spacedBy(2.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + colors.forEach { argb -> + Box( + modifier = Modifier + .size(SPLIT_DOT_SIZE) + .background(eventFill(argb, dark, soften), CircleShape), + ) + } + if (extra > 0) { + Text( + text = "+$extra", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } +} + +/** + * The selected day's events, in the agenda's own row vocabulary so the two + * surfaces read as one app. The date header opens the full Day view, matching + * the Week and Agenda headers (#37). + */ +@Composable +private fun SplitDayPane( + date: LocalDate, + today: LocalDate, + events: List, + zone: TimeZone, + onOpenDay: (LocalDate) -> Unit, + onEventClick: (EventInstance) -> Unit, + onCreateEvent: (LocalDate) -> Unit, + modifier: Modifier = Modifier, +) { + val dimCutoff = LocalDimCutoff.current + Column(modifier = modifier) { + AgendaDayHeader(date = date, today = today, onOpenDay = onOpenDay) + if (events.isEmpty()) { + AgendaEmptyDayRow( + text = stringResource(R.string.month_split_no_events), + onClick = { onCreateEvent(date) }, + ) + } else { + LazyColumn( + // Bottom inset clears the FAB stack so the last row stays tappable. + contentPadding = PaddingValues(bottom = 96.dp), + ) { + itemsIndexed( + items = events, + key = { _, event -> event.instanceId }, + ) { index, event -> + AgendaEventRow( + event = event, + day = date, + zone = zone, + position = positionOf(index, events.size), + dimmed = dimCutoff != null && event.hasEnded(dimCutoff), + onClick = { onEventClick(event) }, + ) + } + } + } + } +} + /** A week the sliding window hasn't loaded yet — same height, so nothing jumps. */ @Composable private fun ContinuousWeekPlaceholder() { diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthUiState.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthUiState.kt index 7e1f3f0..cb59ccf 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthUiState.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthUiState.kt @@ -4,6 +4,7 @@ import de.jeanlucmakiola.calendula.domain.EventInstance import de.jeanlucmakiola.calendula.domain.FailureReason import kotlinx.datetime.DayOfWeek import kotlinx.datetime.LocalDate +import kotlinx.datetime.TimeZone import kotlinx.datetime.YearMonth /** @@ -70,5 +71,12 @@ sealed interface MonthUiState { val today: LocalDate, val weeks: List, val instancesByDay: Map> = emptyMap(), + /** + * Travels on the state rather than being read at the call site, for the + * same reason the agenda does it: a file-level constant would be fixed + * for the process lifetime and drift from the zone the events were laid + * out in after a device time-zone change. + */ + val zone: TimeZone = TimeZone.currentSystemDefault(), ) : MonthUiState } 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 1de6dab..81f33ad 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 @@ -167,21 +167,54 @@ class MonthViewModel @Inject constructor( ) } + // --- Split style (#53) ------------------------------------------------ + // + // The first selected-day concept in the app: the other views drill straight + // into a date, while the split style keeps one selected and lists it below + // the grid. + + private val _selectedDate = MutableStateFlow(todayDate) + val selectedDate: StateFlow = _selectedDate + + /** + * Select [date], following it to its month if it sits in the grid's leading + * or trailing days — tapping a greyed-out day should show that day, not + * silently list a date the grid isn't pointing at. + */ + fun selectDate(date: LocalDate) { + _selectedDate.value = date + val target = YearMonth(date.year, date.month) + if (target != _month.value) _month.value = target + } + + /** + * Move the selection with the month: today if the new month holds it, else + * its 1st. Leaving the old date selected would list a day the grid no longer + * shows. + */ + private fun realignSelection() { + _selectedDate.value = selectionForMonth(_month.value, todayDate) + } + fun goToPrev() { _month.value = _month.value.minus(1, DateTimeUnit.MONTH) + realignSelection() } fun goToNext() { _month.value = _month.value.plus(1, DateTimeUnit.MONTH) + realignSelection() } fun goToToday() { _month.value = YearMonth(todayDate.year, todayDate.month) + _selectedDate.value = todayDate } /** Jump to the month containing [date] (drawer jump-to-date). */ fun goToDate(date: LocalDate) { _month.value = YearMonth(date.year, date.month) + _selectedDate.value = date } private fun buildState( @@ -199,6 +232,7 @@ class MonthViewModel @Inject constructor( today = todayDate, weeks = weeks, instancesByDay = instancesByDay(weeks.flatMap { it.days }, instances, zone), + zone = zone, ) } } @@ -310,6 +344,18 @@ internal fun monthGridRange( private const val WINDOW_PAD = 12 private const val WINDOW_EDGE = 4 +/** + * Which day the split style should select when the grid lands on [month]: + * [today] when the month holds it, otherwise the 1st. Pure so the rule can be + * tested without standing up a view model and a provider behind it. + */ +internal fun selectionForMonth(month: YearMonth, today: LocalDate): LocalDate = + if (YearMonth(today.year, today.month) == month) { + today + } else { + LocalDate(month.year, month.month, 1) + } + /** * The window to load for a visible range, or null to keep the current one. * diff --git a/app/src/test/java/de/jeanlucmakiola/calendula/ui/month/ContinuousWeekIndexTest.kt b/app/src/test/java/de/jeanlucmakiola/calendula/ui/month/ContinuousWeekIndexTest.kt index a45a2c3..2b17915 100644 --- a/app/src/test/java/de/jeanlucmakiola/calendula/ui/month/ContinuousWeekIndexTest.kt +++ b/app/src/test/java/de/jeanlucmakiola/calendula/ui/month/ContinuousWeekIndexTest.kt @@ -4,6 +4,7 @@ import com.google.common.truth.Truth.assertThat import kotlinx.datetime.DateTimeUnit import kotlinx.datetime.DayOfWeek import kotlinx.datetime.LocalDate +import kotlinx.datetime.YearMonth import kotlinx.datetime.plus import org.junit.jupiter.api.Test @@ -101,4 +102,20 @@ class ContinuousWeekIndexTest { loaded = window assertThat(nextLoadWindow(loaded, firstVisible = 2, lastVisible = 7)).isNull() } + + @Test + fun `the split selection follows the month, landing on today when it's there`() { + val today = LocalDate(2026, 6, 10) + assertThat(selectionForMonth(YearMonth(2026, kotlinx.datetime.Month.JUNE), today)) + .isEqualTo(today) + } + + @Test + fun `the split selection falls to the 1st of any other month`() { + val today = LocalDate(2026, 6, 10) + assertThat(selectionForMonth(YearMonth(2026, kotlinx.datetime.Month.JULY), today)) + .isEqualTo(LocalDate(2026, 7, 1)) + assertThat(selectionForMonth(YearMonth(2025, kotlinx.datetime.Month.JUNE), today)) + .isEqualTo(LocalDate(2025, 6, 1)) + } } From 81dcbbdce79bf6e8001e79d2ca890b8e8d10c48d Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Mon, 20 Jul 2026 17:02:18 +0200 Subject: [PATCH 41/78] docs(changelog): month view style (#38, #53) Filed under Unreleased, matching where the agenda widget fix landed after the 2.16.0 section was cut. Fold into 2.16.0 if that section reopens before it merges to main. Co-Authored-By: Claude Opus 4.8 (1M context) --- CHANGELOG.md | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index d10b846..49f8808 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,24 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added +- Choose how the month view is laid out. A new **Month view style** setting + (Settings → Views) offers three ways to read a month, each shown with a + preview of the layout it produces: + - **Pages** — what you have today: one month at a time, swiped sideways. + - **Continuous** — scroll up and down through the weeks without a break + between months. Because the weeks run on unbroken, no month is cut off and + no day appears twice, where paging repeats a boundary week at the end of one + month and the start of the next. The 1st of each month names itself so you + always know where you are, and the title bar keeps up as you scroll ([#38]). + - **Split** — a compact grid showing coloured dots for the days that have + something on them, with the day you tap listed in full underneath. Tap the + date above the list to open the whole day ([#53]). + + The Agenda view is untouched by this and stays available in all three styles — + the split layout lists a single day, while Agenda remains a rolling multi-day + window with its own range settings. + ### Fixed - The "Upcoming" agenda widget now scales its text and rows to the size you give it. Previously it was laid out once for the smallest size and simply stretched @@ -1057,5 +1075,7 @@ automatically, with zero telemetry and no internet permission. [#49]: https://codeberg.org/jlmakiola/calendula/issues/49 [#51]: https://codeberg.org/jlmakiola/calendula/issues/51 [#52]: https://codeberg.org/jlmakiola/calendula/issues/52 +[#38]: https://codeberg.org/jlmakiola/calendula/issues/38 +[#53]: https://codeberg.org/jlmakiola/calendula/issues/53 [#60]: https://codeberg.org/jlmakiola/calendula/issues/60 [#65]: https://codeberg.org/jlmakiola/calendula/issues/65 From 94a82c2f6c9945948cd339a042ffc0babe2e8414 Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Mon, 20 Jul 2026 17:34:16 +0200 Subject: [PATCH 42/78] refactor(settings): make the month style picker a live preview (#38, #53) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The first version of this picker invented its own visual language — free- standing bordered cards with a hand-rolled check — which matched nothing else in the app. It is now the family's standard shape: FullScreenPicker with PickerDescription and connected GroupedRows, tonal highlight and the shared SelectedCheck. Above the rows sits a live, scaled-down Month view that changes as you pick a style. It renders the *real* grid composables rather than a drawing of them, so the preview cannot drift from what it depicts: MonthGrid, ContinuousMonthGrid, SplitMonthGrid and SplitDayPane are now internal rather than private, and the sample month runs through the same layoutMonthWeeks/layoutCalendarWeek the live views use. The month, today's position, week start, colour softening and clock format are all real; only the events are stand-ins, since a settings screen has no business querying the provider for a thumbnail. Selecting applies immediately and leaves the picker open — closing on tap would hide the very thing the screen is for. Back exits, as in the App name picker. Scaling note worth keeping: Modifier.requiredSize looks like the way to force a full-viewport measurement, but it *centres* content that overflows the incoming constraints, which left only the grid's bottom-right corner inside the clip. A layout modifier that measures at Constraints.fixed and reports the scaled size has no overflow to align and no dependence on parent alignment. PickerDescription is internal now rather than duplicated. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../calendula/ui/common/Picker.kt | 2 +- .../calendula/ui/month/MonthScreen.kt | 10 +- .../calendula/ui/month/MonthStylePreview.kt | 268 +++++++++++++++ .../ui/settings/MonthViewStylePicker.kt | 312 ++++-------------- .../calendula/ui/settings/SettingsScreen.kt | 3 + 5 files changed, 339 insertions(+), 256 deletions(-) create mode 100644 app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthStylePreview.kt diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/Picker.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/Picker.kt index c038dee..a5fcb2f 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/Picker.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/Picker.kt @@ -198,7 +198,7 @@ private fun CustomReminderEditor( /** A short explanatory paragraph shown under a picker's title, above the rows. */ @Composable -private fun PickerDescription(text: String) { +internal fun PickerDescription(text: String) { Text( text = text, style = MaterialTheme.typography.bodyMedium, diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthScreen.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthScreen.kt index 6a5e821..d6100b7 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthScreen.kt @@ -454,7 +454,7 @@ private fun MonthTopBar( } @Composable -private fun WeekdayHeader(weekStart: DayOfWeek, showWeekNumbers: Boolean) { +internal fun WeekdayHeader(weekStart: DayOfWeek, showWeekNumbers: Boolean) { val locale = currentLocale() val days = remember(weekStart, locale) { (0 until 7).map { offset -> @@ -503,7 +503,7 @@ private const val MAX_EVENT_ROWS = 3 private val CONTINUOUS_ROW_HEIGHT = 112.dp @Composable -private fun MonthGrid( +internal fun MonthGrid( state: MonthUiState.Success, showWeekNumbers: Boolean, onOpenDay: (LocalDate) -> Unit, @@ -545,7 +545,7 @@ private fun MonthGrid( * duplication #38 asks to be rid of. */ @Composable -private fun ContinuousMonthGrid( +internal fun ContinuousMonthGrid( state: ContinuousMonthUiState.Success, listState: LazyListState, showWeekNumbers: Boolean, @@ -682,7 +682,7 @@ private const val SPLIT_MAX_DOTS = 3 * the layout. The full Day view stays one tap away on the pane's date header. */ @Composable -private fun SplitMonthGrid( +internal fun SplitMonthGrid( state: MonthUiState.Success, selected: LocalDate, showWeekNumbers: Boolean, @@ -812,7 +812,7 @@ private fun SplitDots(events: List, dark: Boolean) { * the Week and Agenda headers (#37). */ @Composable -private fun SplitDayPane( +internal fun SplitDayPane( date: LocalDate, today: LocalDate, events: List, diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthStylePreview.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthStylePreview.kt new file mode 100644 index 0000000..b27b867 --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthStylePreview.kt @@ -0,0 +1,268 @@ +package de.jeanlucmakiola.calendula.ui.month + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clipToBounds +import androidx.compose.ui.layout.layout +import androidx.compose.ui.unit.Constraints +import androidx.compose.ui.graphics.TransformOrigin +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.input.pointer.PointerEventPass +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.platform.LocalConfiguration +import androidx.compose.ui.semantics.clearAndSetSemantics +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import de.jeanlucmakiola.calendula.domain.EventInstance +import kotlinx.datetime.DateTimeUnit +import kotlinx.datetime.DayOfWeek +import kotlinx.datetime.LocalDate +import kotlinx.datetime.TimeZone +import kotlinx.datetime.YearMonth +import kotlinx.datetime.atTime +import kotlinx.datetime.plus +import kotlinx.datetime.toInstant +import kotlinx.datetime.toLocalDateTime +import kotlin.time.Clock +import kotlin.math.roundToInt + +/** + * A live, scaled-down Month view in a given [MonthViewStyle], for the settings + * chooser. + * + * It renders the *real* grid composables rather than a drawing of them, so the + * preview cannot drift from the thing it depicts: change a cell's shape or an + * event bar's colour and every preview follows automatically. The trick is + * [requiredSize] — it ignores the incoming constraints, so the grid lays itself + * out at a full phone width and a plausible viewport height, and a + * [graphicsLayer] scale shrinks the finished layout into the card. + * + * The month, today's position and the week start are all real; only the events + * are stand-ins, since the settings screen has no business querying the + * provider for a thumbnail. + */ +@Composable +internal fun MonthStylePreview( + style: MonthViewStyle, + weekStart: DayOfWeek, + height: Dp, + modifier: Modifier = Modifier, +) { + val screenWidth = LocalConfiguration.current.screenWidthDp.dp + val scale = height.value / VIRTUAL_HEIGHT.value + val zone = remember { TimeZone.currentSystemDefault() } + val today = remember(zone) { Clock.System.now().toLocalDateTime(zone).date } + val sample = remember(today, weekStart, zone) { sampleMonthState(today, weekStart, zone) } + + Box( + modifier = modifier + .clipToBounds() + .background(MaterialTheme.colorScheme.surface) + // A preview is a picture, not a control: swallow touches before the + // grid's own clickables see them, and give screen readers one label + // instead of six weeks of day cells. + .pointerInput(Unit) { + awaitPointerEventScope { + while (true) { + awaitPointerEvent(PointerEventPass.Initial).changes + .forEach { it.consume() } + } + } + } + .clearAndSetSemantics { } + // Measure the grid at a full phone viewport but report the scaled + // size, so the node occupies exactly what it draws. + // + // Modifier.requiredSize would be the obvious way to force the larger + // measurement, but it *centres* content that overflows the incoming + // constraints — which pushed the grid to a negative offset and left + // only its bottom-right corner inside the clip. + .layout { measurable, _ -> + val fullWidth = screenWidth.roundToPx() + val fullHeight = VIRTUAL_HEIGHT.roundToPx() + val placeable = measurable.measure(Constraints.fixed(fullWidth, fullHeight)) + layout((fullWidth * scale).roundToInt(), (fullHeight * scale).roundToInt()) { + placeable.place(0, 0) + } + } + .graphicsLayer { + scaleX = scale + scaleY = scale + transformOrigin = TransformOrigin(0f, 0f) + }, + ) { + Column(Modifier.fillMaxSize()) { + WeekdayHeader(weekStart = weekStart, showWeekNumbers = false) + when (style) { + MonthViewStyle.Paged -> MonthGrid( + state = sample.month, + showWeekNumbers = false, + onOpenDay = {}, + ) + MonthViewStyle.Continuous -> ContinuousMonthGrid( + state = sample.continuous, + listState = rememberLazyListState( + initialFirstVisibleItemIndex = + weekIndexOf(LocalDate(today.year, today.month, 1), weekStart), + ), + showWeekNumbers = false, + onOpenDay = {}, + ) + MonthViewStyle.Split -> { + SplitMonthGrid( + state = sample.month, + selected = today, + showWeekNumbers = false, + onSelectDay = {}, + ) + HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant) + SplitDayPane( + date = today, + today = today, + events = sample.month.instancesByDay[today].orEmpty(), + zone = zone, + onOpenDay = {}, + onEventClick = {}, + onCreateEvent = {}, + modifier = Modifier.fillMaxWidth().height(SPLIT_PANE_HEIGHT), + ) + } + } + } + } +} + +/** + * The viewport the preview pretends to be: a phone's width by the height a + * calendar view gets under the top bar. Scaling from a fixed height keeps the + * three styles comparable — each shows the same slice of screen. + */ +private val VIRTUAL_HEIGHT = 440.dp +private val SPLIT_PANE_HEIGHT = 170.dp + +private class SampleMonth( + val month: MonthUiState.Success, + val continuous: ContinuousMonthUiState.Success, +) + +/** + * A month's worth of stand-in events, laid out through the same + * [layoutMonthWeeks] / [layoutCalendarWeek] the live views use — so the preview + * exercises the real span, lane and overflow logic rather than approximating it. + * + * Colours are raw ARGB on purpose: that is what the provider hands out for an + * event, so a token here would misrepresent what the grid actually renders. + */ +private fun sampleMonthState( + today: LocalDate, + weekStart: DayOfWeek, + zone: TimeZone, +): SampleMonth { + val ym = YearMonth(today.year, today.month) + val first = LocalDate(ym.year, ym.month, 1) + val events = sampleEvents(first, today, zone) + + val weeks = layoutMonthWeeks(ym, weekStart, events, zone) + val month = MonthUiState.Success( + month = ym, + today = today, + weeks = weeks, + instancesByDay = instancesByDay(weeks.flatMap { it.days }, events, zone), + zone = zone, + ) + + // Enough weeks either side of today for the continuous list to fill the + // viewport and still have somewhere to scroll. + val centre = weekIndexOf(today, weekStart) + val window = (centre - 8)..(centre + 8) + val continuous = ContinuousMonthUiState.Success( + today = today, + weeksByIndex = window.associateWith { index -> + val days = (0 until 7).map { + weekStartForIndex(index, weekStart).plus(it, DateTimeUnit.DAY) + } + layoutCalendarWeek(days, events, zone) + }, + weekStart = weekStart, + ) + return SampleMonth(month, continuous) +} + +private val SAMPLE_COLORS = listOf( + 0xFF3F7BD4.toInt(), + 0xFFCE5B4C.toInt(), + 0xFF4E9A6A.toInt(), + 0xFF8A63C7.toInt(), +) + +private fun sampleEvents( + firstOfMonth: LocalDate, + today: LocalDate, + zone: TimeZone, +): List { + var id = 0L + fun next() = ++id + + fun timed(day: LocalDate, hour: Int, length: Int, colorIndex: Int) = EventInstance( + instanceId = next(), + eventId = id, + calendarId = 1L, + title = SAMPLE_TITLES[(id.toInt() - 1) % SAMPLE_TITLES.size], + start = day.atTime(hour, 0).toInstant(zone), + end = day.atTime(hour + length, 0).toInstant(zone), + isAllDay = false, + color = SAMPLE_COLORS[colorIndex % SAMPLE_COLORS.size], + location = null, + ) + + fun allDay(from: LocalDate, days: Int, colorIndex: Int) = EventInstance( + instanceId = next(), + eventId = id, + calendarId = 1L, + title = SAMPLE_TITLES[(id.toInt() - 1) % SAMPLE_TITLES.size], + // All-day events sit at UTC midnights with an exclusive end. + start = from.atTime(0, 0).toInstant(TimeZone.UTC), + end = from.plus(days, DateTimeUnit.DAY).atTime(0, 0).toInstant(TimeZone.UTC), + isAllDay = true, + color = SAMPLE_COLORS[colorIndex % SAMPLE_COLORS.size], + location = null, + ) + + // Spread across the month so most weeks carry something, with one multi-day + // bar to show a span bridging cells and a busy today for the split pane. + return buildList { + add(allDay(firstOfMonth.plus(9, DateTimeUnit.DAY), days = 3, colorIndex = 2)) + add(timed(firstOfMonth.plus(1, DateTimeUnit.DAY), 9, 1, 0)) + add(timed(firstOfMonth.plus(4, DateTimeUnit.DAY), 14, 2, 1)) + add(timed(firstOfMonth.plus(7, DateTimeUnit.DAY), 11, 1, 3)) + add(timed(firstOfMonth.plus(15, DateTimeUnit.DAY), 10, 1, 0)) + add(timed(firstOfMonth.plus(18, DateTimeUnit.DAY), 16, 1, 2)) + add(timed(firstOfMonth.plus(22, DateTimeUnit.DAY), 8, 2, 1)) + add(timed(firstOfMonth.plus(25, DateTimeUnit.DAY), 13, 1, 3)) + // Today, so the split pane's list and the grid's dots both have content. + add(timed(today, 9, 1, 0)) + add(timed(today, 12, 1, 1)) + add(timed(today, 15, 2, 3)) + } +} + +private val SAMPLE_TITLES = listOf( + "Standup", + "Lunch", + "Review", + "Gym", + "Call", + "Workshop", + "Dentist", + "Trip", +) diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/MonthViewStylePicker.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/MonthViewStylePicker.kt index ffb7a8e..e5802c6 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/MonthViewStylePicker.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/MonthViewStylePicker.kt @@ -1,297 +1,109 @@ package de.jeanlucmakiola.calendula.ui.settings +import androidx.compose.animation.Crossfade +import androidx.compose.animation.core.snap +import androidx.compose.animation.core.tween import androidx.compose.foundation.background -import androidx.compose.foundation.border -import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.width -import androidx.compose.foundation.selection.selectable -import androidx.compose.foundation.selection.selectableGroup -import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Check -import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clipToBounds -import androidx.compose.ui.graphics.Color import androidx.compose.ui.res.stringResource -import androidx.compose.ui.semantics.Role -import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import de.jeanlucmakiola.calendula.R +import de.jeanlucmakiola.calendula.ui.common.PickerDescription +import de.jeanlucmakiola.calendula.ui.month.MonthStylePreview import de.jeanlucmakiola.calendula.ui.month.MonthViewStyle import de.jeanlucmakiola.calendula.ui.month.descriptionRes import de.jeanlucmakiola.calendula.ui.month.labelRes import de.jeanlucmakiola.floret.components.FullScreenPicker +import de.jeanlucmakiola.floret.components.GroupedRow +import de.jeanlucmakiola.floret.components.SelectedCheck +import de.jeanlucmakiola.floret.components.positionOf +import de.jeanlucmakiola.floret.identity.rememberReduceMotion +import kotlinx.datetime.DayOfWeek /** - * The Month view style chooser (#38, #53). Each option carries a schematic of the - * layout it produces rather than a bare label — the three differ in *shape*, which - * a word like "Continuous" doesn't convey on its own. + * The Month view style chooser (#38, #53). * - * Tapping applies immediately and the picker stays open, matching the App name - * picker: the change is worth seeing land before leaving. + * A live, scaled-down Month view sits above the options and changes as you pick + * one, so the choice is made by looking rather than by reading "Continuous" and + * guessing. Selecting therefore applies immediately and leaves the picker open — + * closing on tap would hide the very thing the screen is for. Back exits, the + * same as the App name picker, which stays open for the same reason. + * + * Below the preview it is the family's standard picker shape: connected grouped + * rows with a tonal highlight and [SelectedCheck] on the current one. */ @Composable internal fun MonthViewStylePicker( selected: MonthViewStyle, + weekStart: DayOfWeek, onSelect: (MonthViewStyle) -> Unit, onDismiss: () -> Unit, ) { + val options = MonthViewStyle.entries + val reduceMotion = rememberReduceMotion() FullScreenPicker( title = stringResource(R.string.settings_month_view_style), onDismiss = onDismiss, predictiveBack = true, ) { - Text( - text = stringResource(R.string.settings_month_view_style_summary), - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - textAlign = TextAlign.Center, - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 24.dp), - ) - Spacer(Modifier.height(24.dp)) - Column( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 16.dp) - .selectableGroup(), - verticalArrangement = Arrangement.spacedBy(12.dp), - ) { - MonthViewStyle.entries.forEach { style -> - MonthStyleOptionCard( - style = style, - selected = style == selected, - onClick = { onSelect(style) }, - ) - } - } - } -} - -/** - * One selectable style: its schematic, name and a line on what it does. Selection - * reads three ways over — border weight, container tint and a check — so it never - * rests on colour alone. - */ -@Composable -private fun MonthStyleOptionCard( - style: MonthViewStyle, - selected: Boolean, - onClick: () -> Unit, - modifier: Modifier = Modifier, -) { - val shape = RoundedCornerShape(24.dp) - val borderColor = if (selected) { - MaterialTheme.colorScheme.primary - } else { - MaterialTheme.colorScheme.outlineVariant - } - val containerColor = if (selected) { - MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.4f) - } else { - MaterialTheme.colorScheme.surfaceContainerHigh - } - Row( - modifier = modifier - .fillMaxWidth() - .clip(shape) - .background(containerColor) - .border(width = if (selected) 2.dp else 1.dp, color = borderColor, shape = shape) - .selectable(selected = selected, role = Role.RadioButton, onClick = onClick) - .padding(all = 16.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(16.dp), - ) { - MonthStyleSchematic(style) - Column(modifier = Modifier.weight(1f)) { - Text( - text = stringResource(style.labelRes), - style = MaterialTheme.typography.titleMedium, - color = MaterialTheme.colorScheme.onSurface, - ) - Text( - text = stringResource(style.descriptionRes), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - // Selection indicator: a filled check when active, an empty ring otherwise. Box( modifier = Modifier - .size(24.dp) - .clip(CircleShape) - .background(if (selected) MaterialTheme.colorScheme.primary else Color.Transparent) - .then( - if (selected) { - Modifier - } else { - Modifier.border(1.dp, MaterialTheme.colorScheme.outlineVariant, CircleShape) - }, - ), + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 8.dp) + .height(PREVIEW_HEIGHT), contentAlignment = Alignment.Center, ) { - if (selected) { - Icon( - imageVector = Icons.Filled.Check, - contentDescription = null, - tint = MaterialTheme.colorScheme.onPrimary, - modifier = Modifier.size(16.dp), - ) + Crossfade( + targetState = selected, + animationSpec = if (reduceMotion) snap() else tween(durationMillis = 250), + label = "month-style-preview", + ) { shown -> + // The preview renders the real grid at phone size and scales it + // down, so its own corners are square — the frame rounds it. + Box( + modifier = Modifier + .clip(PREVIEW_SHAPE) + .background(MaterialTheme.colorScheme.surface) + .clipToBounds(), + ) { + MonthStylePreview( + style = shown, + weekStart = weekStart, + height = PREVIEW_HEIGHT, + ) + } } } - } -} - -private val SCHEMATIC_WIDTH = 64.dp -private val SCHEMATIC_HEIGHT = 60.dp -private val SCHEMATIC_SHAPE = RoundedCornerShape(10.dp) - -/** - * A miniature of the layout, drawn from plain boxes on theme tokens — small - * enough to read as an icon, literal enough that the three are told apart at a - * glance: pages sit side by side, the continuous stream runs off both edges, the - * split style stacks a dotted grid over a list. - */ -@Composable -private fun MonthStyleSchematic(style: MonthViewStyle, modifier: Modifier = Modifier) { - Box( - modifier = modifier - .size(width = SCHEMATIC_WIDTH, height = SCHEMATIC_HEIGHT) - .clip(SCHEMATIC_SHAPE) - .background(MaterialTheme.colorScheme.surfaceContainerLowest) - .clipToBounds(), - contentAlignment = Alignment.Center, - ) { - when (style) { - MonthViewStyle.Paged -> PagedSchematic() - MonthViewStyle.Continuous -> ContinuousSchematic() - MonthViewStyle.Split -> SplitSchematic() - } - } -} - -/** Two pages side by side, the second peeking in — a swipe away. */ -@Composable -private fun PagedSchematic() { - Row( - modifier = Modifier.fillMaxWidth().padding(start = 6.dp), - horizontalArrangement = Arrangement.spacedBy(6.dp), - ) { - SchematicGrid(rows = 4, modifier = Modifier.weight(1f)) - // The next page, cropped by the container — the swipe affordance. - SchematicGrid(rows = 4, modifier = Modifier.width(20.dp), dim = true) - } -} - -/** One column of weeks running off both edges: no page breaks, no repeated days. */ -@Composable -private fun ContinuousSchematic() { - Column( - modifier = Modifier.fillMaxWidth().padding(horizontal = 6.dp), - verticalArrangement = Arrangement.spacedBy(4.dp), - ) { - // Six rows in a 60dp box overflow deliberately: the stream is clipped top - // and bottom rather than fitting neatly, which is the whole idea. - repeat(6) { index -> - SchematicWeekRow(dim = index == 0 || index == 5) - } - } -} - -/** A squished dotted grid over the selected day's list. */ -@Composable -private fun SplitSchematic() { - Column( - modifier = Modifier.fillMaxWidth().padding(6.dp), - verticalArrangement = Arrangement.spacedBy(5.dp), - ) { - SchematicGrid(rows = 3, dotted = true) - // The day pane: two stand-in event rows. - repeat(2) { - Box( - modifier = Modifier - .fillMaxWidth() - .height(6.dp) - .clip(RoundedCornerShape(3.dp)) - .background(MaterialTheme.colorScheme.primary.copy(alpha = 0.55f)), + PickerDescription(stringResource(R.string.settings_month_view_style_summary)) + options.forEachIndexed { index, style -> + val isSelected = style == selected + GroupedRow( + title = stringResource(style.labelRes), + summary = stringResource(style.descriptionRes), + position = positionOf(index, options.size), + selected = isSelected, + trailing = if (isSelected) { + { SelectedCheck() } + } else { + null + }, + // Applies straight away; the preview above is the confirmation, + // so there is nothing to dismiss for. + onClick = { onSelect(style) }, ) } } } -/** [rows] week rows of seven cells; [dotted] shrinks the cells to event dots. */ -@Composable -private fun SchematicGrid( - rows: Int, - modifier: Modifier = Modifier, - dim: Boolean = false, - dotted: Boolean = false, -) { - Column( - modifier = modifier, - verticalArrangement = Arrangement.spacedBy(if (dotted) 4.dp else 3.dp), - ) { - repeat(rows) { - if (dotted) SchematicDotRow(dim) else SchematicWeekRow(dim) - } - } -} - -/** A week as seven filled cells. */ -@Composable -private fun SchematicWeekRow(dim: Boolean = false, modifier: Modifier = Modifier) { - val color = MaterialTheme.colorScheme.onSurfaceVariant - .copy(alpha = if (dim) 0.18f else 0.38f) - Row( - modifier = modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(2.dp), - ) { - repeat(7) { - Box( - modifier = Modifier - .weight(1f) - .height(5.dp) - .clip(RoundedCornerShape(1.5.dp)) - .background(color), - ) - } - } -} - -/** A week as seven event dots — the split style's compact grid. */ -@Composable -private fun SchematicDotRow(dim: Boolean = false, modifier: Modifier = Modifier) { - val color = MaterialTheme.colorScheme.onSurfaceVariant - .copy(alpha = if (dim) 0.18f else 0.38f) - Row( - modifier = modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(2.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - repeat(7) { - Box( - modifier = Modifier - .weight(1f) - .height(3.dp) - .clip(CircleShape) - .background(color), - ) - } - } -} +private val PREVIEW_HEIGHT = 240.dp +private val PREVIEW_SHAPE = RoundedCornerShape(12.dp) diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/SettingsScreen.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/SettingsScreen.kt index 6e983b9..c490375 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/SettingsScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/SettingsScreen.kt @@ -104,6 +104,7 @@ import de.jeanlucmakiola.floret.reminders.reminderOverrideFor import de.jeanlucmakiola.calendula.data.prefs.ThemeMode import de.jeanlucmakiola.calendula.data.prefs.TimeFormatPref import de.jeanlucmakiola.calendula.data.prefs.WeekStartPref +import de.jeanlucmakiola.calendula.data.prefs.resolveFirstDay import de.jeanlucmakiola.calendula.domain.EventFormField import de.jeanlucmakiola.calendula.domain.contacts.SpecialDateType import de.jeanlucmakiola.calendula.qs.NewEventTileService @@ -915,6 +916,8 @@ private fun ViewsScreen( if (showMonthStyle) { MonthViewStylePicker( selected = state.monthViewStyle, + // The preview is a real grid, so it uses the real week start too. + weekStart = state.weekStart.resolveFirstDay(currentLocale()), onSelect = viewModel::setMonthViewStyle, onDismiss = { showMonthStyle = false }, ) From 6e3d10858fd7e0f3d1e527b9d1a66e09613a3c53 Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Mon, 20 Jul 2026 17:54:26 +0200 Subject: [PATCH 43/78] refactor(month): make the continuous style a stack of month blocks (#38) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The continuous style streamed weeks with no boundaries at all, which left it hard to tell where one month ended: the only marker was a "Jul 1" label on the 1st, and every boundary week mixed two months' days into one row. It is now a vertical stack of self-contained month blocks. Each block shows only its own days — the boundary week keeps its seven columns so nothing shifts sideways, but the neighbour month's cells are blank rather than filled with duplicates of days shown again a block later — under a sticky month header with whitespace either side. Scrolling stays continuous; only the reading changes. - The coordinate space moves from absolute week index to absolute month index (two LazyColumn items per month: header, then block). Unlike week indices, it doesn't depend on the week-start preference, so changing that reflows the rows inside a block without moving the block or losing the scroll position. - The sliding data window now loads months rather than weeks, widened to whole grid weeks at both ends so a bar reaching into a block from a clipped-off day still renders. - `clipWeekToMonth` is the pure seam: it drops the neighbour month's pills and counts and cuts spanning bars back to the month's own columns, keeping a flat cap on the cut side so a bar reads as continuing past the block. - The top bar carries the year in this style — the block's own header names the month, so repeating it two lines up was pure duplication. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../calendula/ui/month/MonthScreen.kt | 265 +++++++++++------- .../calendula/ui/month/MonthStylePreview.kt | 23 +- .../calendula/ui/month/MonthUiState.kt | 15 +- .../calendula/ui/month/MonthViewModel.kt | 161 +++++++---- app/src/main/res/values/strings.xml | 2 +- .../calendula/ui/month/ClipWeekToMonthTest.kt | 120 ++++++++ .../ui/month/ContinuousMonthIndexTest.kt | 138 +++++++++ .../ui/month/ContinuousWeekIndexTest.kt | 121 -------- 8 files changed, 551 insertions(+), 294 deletions(-) create mode 100644 app/src/test/java/de/jeanlucmakiola/calendula/ui/month/ClipWeekToMonthTest.kt create mode 100644 app/src/test/java/de/jeanlucmakiola/calendula/ui/month/ContinuousMonthIndexTest.kt delete mode 100644 app/src/test/java/de/jeanlucmakiola/calendula/ui/month/ContinuousWeekIndexTest.kt diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthScreen.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthScreen.kt index d6100b7..40474b5 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthScreen.kt @@ -23,7 +23,6 @@ import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyListState -import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.selection.selectable @@ -49,7 +48,6 @@ import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue -import androidx.compose.runtime.key import androidx.compose.runtime.snapshotFlow import androidx.compose.runtime.mutableFloatStateOf import androidx.compose.runtime.mutableIntStateOf @@ -104,12 +102,10 @@ import de.jeanlucmakiola.calendula.ui.common.next import de.jeanlucmakiola.floret.time.isoWeekNumber import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.launch -import kotlinx.datetime.DateTimeUnit import kotlinx.datetime.DayOfWeek import kotlinx.datetime.LocalDate import kotlinx.datetime.TimeZone import kotlinx.datetime.YearMonth -import kotlinx.datetime.plus import kotlinx.datetime.toJavaLocalDate import kotlinx.datetime.toLocalDateTime import kotlin.time.Clock @@ -161,24 +157,17 @@ fun MonthScreen( else -> (state as? MonthUiState.Success)?.today } ?: Clock.System.now().toLocalDateTime(TimeZone.currentSystemDefault()).date - // Keyed on the week start: week indices are anchored on it, so a changed - // week start would leave the saved scroll offset pointing at a different week. - val listState = key(weekStart) { - rememberLazyListState( - initialFirstVisibleItemIndex = - weekIndexOf(LocalDate(month.year, month.month, 1), weekStart), - ) - } + // Month indices don't move with the week-start preference, so unlike the + // week-indexed stream this replaced, the list state survives a change to it. + val listState = rememberLazyListState( + initialFirstVisibleItemIndex = itemIndexForMonth(monthIndexOf(month)), + ) - // The continuous stream has no single "current" month, so the title takes the - // one the viewport mostly sits in: the midweek day of the row below the top - // edge, which flips over as that month's first full week comes into view. - val visibleMonth by remember(weekStart) { - derivedStateOf { - val midweek = weekStartForIndex(listState.firstVisibleItemIndex + 1, weekStart) - .plus(3, DateTimeUnit.DAY) - YearMonth(midweek.year, midweek.month) - } + // Whichever month's block the top of the viewport is in. Its sticky header + // is the first visible item for all but the last sliver of a block, so this + // flips exactly when the header does. + val visibleMonth by remember { + derivedStateOf { yearMonthForIndex(monthIndexForItem(listState.firstVisibleItemIndex)) } } val titleMonth = if (continuous) visibleMonth else month @@ -188,16 +177,23 @@ fun MonthScreen( if (!continuous) return@LaunchedEffect snapshotFlow { val visible = listState.layoutInfo.visibleItemsInfo - (visible.firstOrNull()?.index ?: 0) to (visible.lastOrNull()?.index ?: 0) + monthIndexForItem(visible.firstOrNull()?.index ?: 0) to + monthIndexForItem(visible.lastOrNull()?.index ?: 0) } .distinctUntilChanged() - .collect { (first, last) -> viewModel.onVisibleWeeksChanged(first, last) } + .collect { (first, last) -> viewModel.onVisibleMonthsChanged(first, last) } } val isOnCurrentMonth = titleMonth == YearMonth(today.year, today.month) - // Drives whether the title carries the year. - val currentYear = today.year + // The continuous style names the month on the block's own sticky header, so + // the bar carries the year instead of repeating it two lines further down. + val locale = currentLocale() + val topBarTitle = if (continuous) { + titleMonth.year.toString() + } else { + formatMonthTitle(titleMonth, locale, currentYear = today.year) + } // Slide direction for the grid transition: +1 = next, -1 = prev, 0 = jump (no slide). var slideDir by remember { mutableIntStateOf(0) } @@ -215,7 +211,11 @@ fun MonthScreen( // scrolls to today's week instead — there is nothing to slide. val jumpToToday = { if (continuous) { - scope.launch { listState.animateScrollToItem(weekIndexOf(today, weekStart)) } + scope.launch { + listState.animateScrollToItem( + itemIndexForMonth(monthIndexOf(YearMonth(today.year, today.month))), + ) + } Unit } else { slideDir = when (val s = state) { @@ -231,7 +231,7 @@ fun MonthScreen( if (continuous) { scope.launch { listState.animateScrollToItem( - weekIndexOf(LocalDate(target.year, target.month, 1), weekStart), + itemIndexForMonth(monthIndexOf(YearMonth(target.year, target.month))), ) } } else { @@ -268,8 +268,7 @@ fun MonthScreen( modifier = modifier.nestedScroll(scrollBehavior.nestedScrollConnection), topBar = { MonthTopBar( - month = titleMonth, - currentYear = currentYear, + title = topBarTitle, selectedView = selectedView, onCycleView = { onSelectView(selectedView.next(quickSwitchViews)) }, onOpenDrawer = { scope.launch { drawerState.open() } }, @@ -409,8 +408,7 @@ private fun ContinuousMonthContent( @OptIn(ExperimentalMaterial3Api::class) @Composable private fun MonthTopBar( - month: YearMonth, - currentYear: Int, + title: String, selectedView: CalendarView, onCycleView: () -> Unit, onOpenDrawer: () -> Unit, @@ -419,11 +417,10 @@ private fun MonthTopBar( onToday: () -> Unit, scrollBehavior: androidx.compose.material3.TopAppBarScrollBehavior, ) { - val locale = currentLocale() TopAppBar( title = { Text( - text = formatMonthTitle(month, locale, currentYear), + text = title, style = MaterialTheme.typography.titleLarge, ) }, @@ -502,6 +499,14 @@ private const val MAX_EVENT_ROWS = 3 */ private val CONTINUOUS_ROW_HEIGHT = 112.dp +/** + * Whitespace below a month block, before the next month's header. Paired with the + * header's own top padding it gives the seam between two months roughly a row's + * worth of air — enough that the blocks read as separate without a rule between + * them. + */ +private val CONTINUOUS_MONTH_GAP = 20.dp + @Composable internal fun MonthGrid( state: MonthUiState.Success, @@ -534,15 +539,19 @@ internal fun MonthGrid( } /** - * The continuous grid (#38): one uninterrupted vertical stream of weeks. + * The continuous grid (#38): months stacked into one vertical scroll, each a + * self-contained block under its own sticky header. * - * The list is indexed by *absolute week number*, so a row's identity never - * changes and there is no page seam to scroll across. Weeks the loaded window - * hasn't reached yet render as a skeleton and pull the window along behind them. + * A block shows *only its own days* — the boundary week keeps its seven columns + * so nothing shifts sideways, but the neighbour month's cells are left blank + * rather than filled with dimmed duplicates of days shown again a block later. + * The whitespace either side of a header is what separates one month from the + * next; scrolling itself never stops at a page seam. * - * Deliberately not a stack of month grids — that would still repeat a boundary - * week at the end of one month and the start of the next, which is exactly the - * duplication #38 asks to be rid of. + * The list is indexed by *absolute month index* (two items per month: header, + * then block), so a block's identity never changes and the week-start preference + * can't move it. Months the loaded window hasn't reached render as a skeleton of + * the right height and pull the window along behind them. */ @Composable internal fun ContinuousMonthGrid( @@ -552,30 +561,72 @@ internal fun ContinuousMonthGrid( onOpenDay: (LocalDate) -> Unit, modifier: Modifier = Modifier, ) { - val weekCount = remember(state.weekStart) { continuousWeekCount(state.weekStart) } + val monthCount = remember { continuousMonthCount() } + val todayMonth = remember(state.today) { YearMonth(state.today.year, state.today.month) } LazyColumn( state = listState, - modifier = modifier - .fillMaxSize() - .padding(horizontal = 8.dp), - verticalArrangement = Arrangement.spacedBy(2.dp), + modifier = modifier.fillMaxSize(), // Bottom inset clears the FAB stack so the last row stays tappable. - contentPadding = PaddingValues(top = 4.dp, bottom = 96.dp), + contentPadding = PaddingValues(bottom = 96.dp), ) { - items(count = weekCount, key = { it }) { index -> - val week = state.weeksByIndex[index] - if (week == null) { - ContinuousWeekPlaceholder() - } else { - MonthWeekRow( - week = week, + repeat(monthCount) { index -> + val month = yearMonthForIndex(index) + stickyHeader(key = "header-$index", contentType = "month-header") { + ContinuousMonthHeader( + month = month, + currentYear = state.today.year, + isCurrent = month == todayMonth, + ) + } + item(key = "month-$index", contentType = "month-block") { + ContinuousMonthBlock( + month = month, + weeks = state.monthsByIndex[index], + weekStart = state.weekStart, today = state.today, - // Every day in the stream belongs to a month equally — there - // is no "other month" to recede here. - inMonth = { true }, showWeekNumbers = showWeekNumbers, onOpenDay = onOpenDay, - labelMonthOnFirst = true, + ) + } + } + } +} + +/** + * One month's week rows. Sized by [weekRowsInMonth] even before its data has + * loaded, so the skeleton occupies exactly what the real block will and a scroll + * across unloaded months doesn't jump when they arrive. + */ +@Composable +private fun ContinuousMonthBlock( + month: YearMonth, + weeks: List?, + weekStart: DayOfWeek, + today: LocalDate, + showWeekNumbers: Boolean, + onOpenDay: (LocalDate) -> Unit, +) { + val rowCount = remember(month, weekStart) { weekRowsInMonth(month, weekStart) } + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 8.dp) + .padding(bottom = CONTINUOUS_MONTH_GAP), + verticalArrangement = Arrangement.spacedBy(2.dp), + ) { + if (weeks == null) { + repeat(rowCount) { ContinuousWeekPlaceholder() } + } else { + weeks.forEach { week -> + MonthWeekRow( + week = week, + today = today, + inMonth = { it.month == month.month && it.year == month.year }, + // The block owns its month alone: a day from either + // neighbour is left out entirely rather than dimmed. + blankOutside = true, + showWeekNumbers = showWeekNumbers, + onOpenDay = onOpenDay, modifier = Modifier .fillMaxWidth() .height(CONTINUOUS_ROW_HEIGHT), @@ -585,6 +636,27 @@ internal fun ContinuousMonthGrid( } } +/** + * The month label that pins above its block. Opaque `surface` so the rows scroll + * under it cleanly, and generous vertical padding — the whitespace around the + * label is what makes each month read as its own section. + */ +@Composable +private fun ContinuousMonthHeader(month: YearMonth, currentYear: Int, isCurrent: Boolean) { + val locale = currentLocale() + Text( + text = formatMonthTitle(month, locale, currentYear), + style = MaterialTheme.typography.titleMedium, + color = if (isCurrent) MaterialTheme.colorScheme.primary + else MaterialTheme.colorScheme.onSurface, + modifier = Modifier + .fillMaxWidth() + .background(MaterialTheme.colorScheme.surface) + .padding(horizontal = 12.dp) + .padding(top = 20.dp, bottom = 10.dp), + ) +} + /** * The month-changing horizontal swipe, shared by the paged and split styles. * Accumulates the drag and commits past a small threshold on release — the grid @@ -888,7 +960,7 @@ private fun MonthWeekRow( showWeekNumbers: Boolean, onOpenDay: (LocalDate) -> Unit, modifier: Modifier = Modifier, - labelMonthOnFirst: Boolean = false, + blankOutside: Boolean = false, ) { val dark = isSystemInDarkTheme() val laneCount = (week.spans.maxOfOrNull { it.lane } ?: -1) + 1 @@ -925,8 +997,13 @@ private fun MonthWeekRow( .fillMaxHeight() .padding(horizontal = CELL_GAP, vertical = 1.dp) .background( - color = if (inMonth(d)) MaterialTheme.colorScheme.surfaceContainer - else MaterialTheme.colorScheme.surfaceContainerLow, + color = when { + inMonth(d) -> MaterialTheme.colorScheme.surfaceContainer + // A blanked cell draws nothing at all: it is + // the gap that shows where the month starts. + blankOutside -> Color.Transparent + else -> MaterialTheme.colorScheme.surfaceContainerLow + }, shape = CELL_SHAPE, ), ) @@ -936,20 +1013,16 @@ private fun MonthWeekRow( Column(Modifier.fillMaxSize().padding(top = CELL_TOP_PADDING)) { Row(Modifier.fillMaxWidth()) { week.days.forEach { d -> - DayNumberCell( - date = d, - isToday = d == today, - inMonth = inMonth(d), - // Continuous has no month boundaries to dim across, so - // the 1st names its month instead — the only marker - // telling one month from the next inside the stream. - monthLabel = if (labelMonthOnFirst && d.day == 1) { - shortMonthName(d) - } else { - null - }, - modifier = Modifier.weight(1f), - ) + if (blankOutside && !inMonth(d)) { + Spacer(Modifier.weight(1f).height(DAY_NUMBER_HEIGHT)) + } else { + DayNumberCell( + date = d, + isToday = d == today, + inMonth = inMonth(d), + modifier = Modifier.weight(1f), + ) + } } } // Breathing room between the day number (and today's circle) and the @@ -1030,17 +1103,22 @@ private fun MonthWeekRow( } // Tap layer: in month view a tap on any day opens that day. Padded and - // clipped to the background pill so the ripple matches it. + // clipped to the background pill so the ripple matches it. A blanked + // cell isn't part of this month, so it takes no taps either. Row(Modifier.matchParentSize()) { week.days.forEach { d -> - Box( - Modifier - .weight(1f) - .fillMaxHeight() - .padding(horizontal = CELL_GAP, vertical = 1.dp) - .clip(CELL_SHAPE) - .clickable { onOpenDay(d) }, - ) + if (blankOutside && !inMonth(d)) { + Spacer(Modifier.weight(1f).fillMaxHeight()) + } else { + Box( + Modifier + .weight(1f) + .fillMaxHeight() + .padding(horizontal = CELL_GAP, vertical = 1.dp) + .clip(CELL_SHAPE) + .clickable { onOpenDay(d) }, + ) + } } } } @@ -1080,7 +1158,6 @@ private fun DayNumberCell( isToday: Boolean, inMonth: Boolean, modifier: Modifier = Modifier, - monthLabel: String? = null, ) { Box( modifier = modifier.height(DAY_NUMBER_HEIGHT), @@ -1100,16 +1177,6 @@ private fun DayNumberCell( color = MaterialTheme.colorScheme.onPrimary, ) } - } else if (monthLabel != null) { - Text( - text = "$monthLabel ${date.day}", - style = MaterialTheme.typography.labelMedium, - fontWeight = FontWeight.Bold, - color = MaterialTheme.colorScheme.primary, - maxLines = 1, - overflow = TextOverflow.Visible, - softWrap = false, - ) } else { Text( text = date.day.toString(), @@ -1121,16 +1188,6 @@ private fun DayNumberCell( } } -/** Locale-short month name ("Jul", "Juli"), for the 1st in the continuous grid. */ -@Composable -private fun shortMonthName(date: LocalDate): String { - val locale = currentLocale() - return remember(date.month, locale) { - java.time.Month.of(date.month.ordinal + 1) - .getDisplayName(JavaTextStyle.SHORT, locale) - } -} - /** A filled event pill/bar — softened (or raw) fill, title clipped to one line. */ @Composable private fun MonthBar( diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthStylePreview.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthStylePreview.kt index b27b867..d113cf3 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthStylePreview.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthStylePreview.kt @@ -112,8 +112,9 @@ internal fun MonthStylePreview( MonthViewStyle.Continuous -> ContinuousMonthGrid( state = sample.continuous, listState = rememberLazyListState( - initialFirstVisibleItemIndex = - weekIndexOf(LocalDate(today.year, today.month, 1), weekStart), + initialFirstVisibleItemIndex = itemIndexForMonth( + monthIndexOf(YearMonth(today.year, today.month)), + ), ), showWeekNumbers = false, onOpenDay = {}, @@ -157,7 +158,7 @@ private class SampleMonth( /** * A month's worth of stand-in events, laid out through the same - * [layoutMonthWeeks] / [layoutCalendarWeek] the live views use — so the preview + * [layoutMonthWeeks] / [clipWeekToMonth] the live views use — so the preview * exercises the real span, lane and overflow logic rather than approximating it. * * Colours are raw ARGB on purpose: that is what the provider hands out for an @@ -181,17 +182,15 @@ private fun sampleMonthState( zone = zone, ) - // Enough weeks either side of today for the continuous list to fill the - // viewport and still have somewhere to scroll. - val centre = weekIndexOf(today, weekStart) - val window = (centre - 8)..(centre + 8) + // This month and the next: enough for the preview to show a month block, the + // whitespace under it and the following month's header coming up. + val centre = monthIndexOf(ym) + val window = centre..(centre + 1) val continuous = ContinuousMonthUiState.Success( today = today, - weeksByIndex = window.associateWith { index -> - val days = (0 until 7).map { - weekStartForIndex(index, weekStart).plus(it, DateTimeUnit.DAY) - } - layoutCalendarWeek(days, events, zone) + monthsByIndex = window.associateWith { index -> + val m = yearMonthForIndex(index) + layoutMonthWeeks(m, weekStart, events, zone).map { clipWeekToMonth(it, m) } }, weekStart = weekStart, ) diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthUiState.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthUiState.kt index cb59ccf..91ff84a 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthUiState.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthUiState.kt @@ -40,18 +40,21 @@ data class MonthWeek( ) /** - * State for the continuous style (#38). Weeks are keyed by absolute week index - * rather than gathered into months: the whole point of the style is that there - * are no month boundaries to scroll across, so there is no "current month" here - * and no notion of an out-of-month day. [weeksByIndex] holds only the loaded - * window; indices outside it render as placeholders until the window catches up. + * State for the continuous style (#38): a vertical stream of *self-contained* + * months rather than one undifferentiated run of weeks. Each month is keyed by + * absolute month index and carries only its own days — the boundary week keeps + * its seven columns so the grid geometry never shifts, but the neighbour month's + * dates are clipped out of it (see `clipWeekToMonth`) and render as blanks. + * + * [monthsByIndex] holds only the loaded window; indices outside it render as + * placeholders until the window catches up. */ sealed interface ContinuousMonthUiState { data object Loading : ContinuousMonthUiState data class Failure(val reason: FailureReason) : ContinuousMonthUiState data class Success( val today: LocalDate, - val weeksByIndex: Map, + val monthsByIndex: Map>, val weekStart: DayOfWeek, ) : ContinuousMonthUiState } 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 81f33ad..dd3e64b 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 @@ -25,11 +25,11 @@ import kotlinx.coroutines.flow.stateIn import kotlinx.datetime.DateTimeUnit import kotlinx.datetime.DayOfWeek import kotlinx.datetime.LocalDate +import kotlinx.datetime.Month import kotlinx.datetime.TimeZone import kotlinx.datetime.YearMonth import kotlinx.datetime.atStartOfDayIn import kotlinx.datetime.atTime -import kotlinx.datetime.daysUntil import kotlinx.datetime.minus import kotlinx.datetime.plus import kotlinx.datetime.toInstant @@ -102,24 +102,30 @@ class MonthViewModel @Inject constructor( // --- Continuous style (#38) ------------------------------------------- // - // The continuous grid is one endless stream of weeks, so it can't load "a - // month" — it loads a sliding window of week indices around whatever is on - // screen. The window only moves when the visible range comes within - // WINDOW_EDGE weeks of a loaded edge, so a scroll re-queries occasionally - // rather than on every frame. + // The continuous grid scrolls through every month there is, so it can't load + // one month at a time — it loads a sliding window of month indices around + // whatever is on screen. The window only moves when the visible range comes + // within WINDOW_EDGE months of a loaded edge, so a scroll re-queries + // occasionally rather than on every frame. - // Seeded around today so the first frame has data. The week-start preference - // hasn't arrived yet, but anchoring on a different day shifts an index by at - // most one week — well inside the pad, and the first scroll report corrects it. - private val _loadedWeeks = MutableStateFlow( - weekIndexOf(todayDate, DayOfWeek.MONDAY).let { it - WINDOW_PAD..it + WINDOW_PAD }, + // Seeded around today so the first frame has data. + private val _loadedMonths = MutableStateFlow( + monthIndexOf(YearMonth(todayDate.year, todayDate.month)) + .let { it - WINDOW_PAD..it + WINDOW_PAD }, ) val continuousState: StateFlow = - combine(_loadedWeeks, weekStart) { window, ws -> window to ws } + combine(_loadedMonths, weekStart) { window, ws -> window to ws } .flatMapLatest { (window, ws) -> - val first = weekStartForIndex(window.first, ws) - val last = weekStartForIndex(window.last, ws).plus(6, DateTimeUnit.DAY) + // Widened to whole grid weeks at both ends: a month block still + // has to know about an event that starts in the boundary week's + // clipped-off days, or a bar running into the block would vanish. + val first = firstOfMonth(yearMonthForIndex(window.first)).startOfGridWeek(ws) + val last = firstOfMonth(yearMonthForIndex(window.last)) + .plus(1, DateTimeUnit.MONTH) + .minus(1, DateTimeUnit.DAY) + .startOfGridWeek(ws) + .plus(6, DateTimeUnit.DAY) val range = first.atStartOfDayIn(zone)..last.atTime(23, 59, 59).toInstant(zone) combine( repository.calendars(), @@ -137,12 +143,12 @@ class MonthViewModel @Inject constructor( ) /** - * Report which absolute week indices are on screen. Cheap to call on every + * Report which absolute month indices are on screen. Cheap to call on every * scroll frame: it returns immediately unless the visible range has drifted * close enough to a loaded edge to warrant a wider query. */ - fun onVisibleWeeksChanged(firstIndex: Int, lastIndex: Int) { - nextLoadWindow(_loadedWeeks.value, firstIndex, lastIndex)?.let { _loadedWeeks.value = it } + fun onVisibleMonthsChanged(firstIndex: Int, lastIndex: Int) { + nextLoadWindow(_loadedMonths.value, firstIndex, lastIndex)?.let { _loadedMonths.value = it } } private fun buildContinuousState( @@ -154,15 +160,13 @@ class MonthViewModel @Inject constructor( if (calendars.isEmpty()) { return ContinuousMonthUiState.Failure(FailureReason.NoCalendarsConfigured) } - val weeks = window.associateWith { index -> - val days = (0 until 7).map { - weekStartForIndex(index, weekStart).plus(it, DateTimeUnit.DAY) - } - layoutCalendarWeek(days, instances, zone) + val months = window.associateWith { index -> + val ym = yearMonthForIndex(index) + layoutMonthWeeks(ym, weekStart, instances, zone).map { clipWeekToMonth(it, ym) } } return ContinuousMonthUiState.Success( today = todayDate, - weeksByIndex = weeks, + monthsByIndex = months, weekStart = weekStart, ) } @@ -252,12 +256,8 @@ internal fun layoutMonthWeeks( 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 + val gridStart = firstOfMonth(ym).startOfGridWeek(weekStart) + val weekCount = weekRowsInMonth(ym, weekStart) return (0 until weekCount).map { row -> val days = (0 until 7).map { gridStart.plus(row * 7 + it, DateTimeUnit.DAY) } @@ -336,13 +336,13 @@ internal fun monthGridRange( } /** - * How many weeks the continuous window loads beyond the visible range, and how + * How many months the continuous window loads beyond the visible range, and how * close the visible range may drift to a loaded edge before it reloads. The pad * is generous relative to the trigger so a steady scroll crosses the trigger - * well before it would run out of laid-out weeks. + * well before it would run out of laid-out months. */ -private const val WINDOW_PAD = 12 -private const val WINDOW_EDGE = 4 +private const val WINDOW_PAD = 4 +private const val WINDOW_EDGE = 2 /** * Which day the split style should select when the grid lands on [month]: @@ -370,28 +370,89 @@ internal fun nextLoadWindow(loaded: IntRange, firstVisible: Int, lastVisible: In } /** - * The continuous grid addresses weeks by an absolute index rather than a - * (month, row) pair, so the list has one stable, gap-free coordinate space to - * scroll through and key its items by. Index 0 is the first week of 1900 under - * the active week-start; the list runs to [CONTINUOUS_WEEK_COUNT]. + * The continuous grid addresses months by an absolute index, so the list has one + * stable, gap-free coordinate space to scroll through and key its items by. + * Index 0 is January 1900; the list runs to [continuousMonthCount]. * - * The epoch is deliberately far in the past so every index is non-negative, - * which keeps the LazyColumn's item indices and week indices the same number. + * Unlike the week indexing this replaced, the coordinate space doesn't depend on + * the week-start preference — changing it reflows the rows *inside* a month + * block but never moves the block, so a scroll position stays put. */ -private val WEEK_INDEX_EPOCH = LocalDate(1900, 1, 1) -private val WEEK_INDEX_END = LocalDate(2100, 12, 31) +private const val MONTH_INDEX_EPOCH_YEAR = 1900 +private const val MONTH_INDEX_END_YEAR = 2100 -internal fun weekIndexOf(date: LocalDate, weekStart: DayOfWeek): Int { - val base = WEEK_INDEX_EPOCH.startOfGridWeek(weekStart) - return base.daysUntil(date.startOfGridWeek(weekStart)) / 7 +internal fun monthIndexOf(ym: YearMonth): Int = + (ym.year - MONTH_INDEX_EPOCH_YEAR) * 12 + ym.month.ordinal + +internal fun yearMonthForIndex(index: Int): YearMonth = YearMonth( + MONTH_INDEX_EPOCH_YEAR + index / 12, + Month.entries[index % 12], +) + +/** Total months the continuous grid scrolls through (1900 → 2100). */ +internal fun continuousMonthCount(): Int = + (MONTH_INDEX_END_YEAR - MONTH_INDEX_EPOCH_YEAR + 1) * 12 + +/** + * Each month occupies two LazyColumn items — its sticky header and the block of + * week rows under it — so the two coordinate spaces differ by a factor of two. + * Scrolling to a month means scrolling to its header. + */ +internal fun itemIndexForMonth(monthIndex: Int): Int = monthIndex * 2 + +internal fun monthIndexForItem(itemIndex: Int): Int = itemIndex / 2 + +internal fun firstOfMonth(ym: YearMonth): LocalDate = LocalDate(ym.year, ym.month, 1) + +/** How many week rows [ym] needs under [weekStart] — 4 (a non-leap February), 5 or 6. */ +internal fun weekRowsInMonth(ym: YearMonth, weekStart: DayOfWeek): Int { + val first = firstOfMonth(ym) + val leadOffset = ((first.dayOfWeek.ordinal - weekStart.ordinal) + 7) % 7 + val daysInMonth = java.time.YearMonth.of(ym.year, ym.month.ordinal + 1).lengthOfMonth() + return (leadOffset + daysInMonth + 6) / 7 } -internal fun weekStartForIndex(index: Int, weekStart: DayOfWeek): LocalDate = - WEEK_INDEX_EPOCH.startOfGridWeek(weekStart).plus(index * 7, DateTimeUnit.DAY) - -/** Total weeks the continuous grid scrolls through (1900 → 2100). */ -internal fun continuousWeekCount(weekStart: DayOfWeek): Int = - weekIndexOf(WEEK_INDEX_END, weekStart) + 1 +/** + * Strip everything outside [month] from a boundary week row, for the continuous + * style's self-contained month blocks. + * + * The seven [MonthWeek.days] stay put — the row keeps its column geometry, and + * the grid decides which cells to draw blank — but the neighbour month's events + * are removed: its pills and counts go, and a bar reaching in from (or out into) + * it is cut back to the month's own columns. A bar cut this way keeps a flat + * cap, which is what says "this continues past the block" rather than "it ends + * here". + */ +internal fun clipWeekToMonth(week: MonthWeek, month: YearMonth): MonthWeek { + val inMonth = week.days.map { it.year == month.year && it.month == month.month } + val firstCol = inMonth.indexOfFirst { it } + val lastCol = inMonth.indexOfLast { it } + // Can't happen for a row of the month's own grid, but a caller-proof no-op + // beats an out-of-range crash. + if (firstCol == -1) { + return week.copy(spans = emptyList(), timedByDay = emptyMap(), countByDay = emptyMap()) + } + val spans = week.spans.mapNotNull { span -> + val start = maxOf(span.startCol, firstCol) + val end = minOf(span.endCol, lastCol) + if (start > end) { + null + } else { + span.copy( + startCol = start, + endCol = end, + continuesLeft = span.continuesLeft || start > span.startCol, + continuesRight = span.continuesRight || end < span.endCol, + ) + } + } + val own = week.days.filterIndexed { col, _ -> inMonth[col] } + return week.copy( + spans = spans, + timedByDay = week.timedByDay.filterKeys { it in own }, + countByDay = week.countByDay.filterKeys { it in own }, + ) +} internal fun LocalDate.startOfGridWeek(weekStart: DayOfWeek): LocalDate { // DayOfWeek.ordinal: MONDAY=0..SUNDAY=6 → identical to ISO ordering. diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 988f6c7..5bd8dd6 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -379,7 +379,7 @@ Pages One month at a time. Swipe sideways to change month. Continuous - Scroll up and down through the weeks. No month is cut off and no day appears twice. + Scroll up and down through the months. Each month keeps to itself under its own heading, with no days borrowed from its neighbours. Split A compact grid with dots for events, and the day you tap listed underneath. Nothing scheduled diff --git a/app/src/test/java/de/jeanlucmakiola/calendula/ui/month/ClipWeekToMonthTest.kt b/app/src/test/java/de/jeanlucmakiola/calendula/ui/month/ClipWeekToMonthTest.kt new file mode 100644 index 0000000..3193ded --- /dev/null +++ b/app/src/test/java/de/jeanlucmakiola/calendula/ui/month/ClipWeekToMonthTest.kt @@ -0,0 +1,120 @@ +package de.jeanlucmakiola.calendula.ui.month + +import com.google.common.truth.Truth.assertThat +import de.jeanlucmakiola.calendula.domain.EventInstance +import kotlinx.datetime.DateTimeUnit +import kotlinx.datetime.DayOfWeek +import kotlinx.datetime.LocalDate +import kotlinx.datetime.Month +import kotlinx.datetime.TimeZone +import kotlinx.datetime.YearMonth +import kotlinx.datetime.atTime +import kotlinx.datetime.plus +import kotlinx.datetime.toInstant +import org.junit.jupiter.api.Test + +/** + * What makes the continuous style's month blocks self-contained: a boundary week + * keeps its seven columns but carries only the block's own month. + */ +class ClipWeekToMonthTest { + + private val zone = TimeZone.UTC + private val jul26 = YearMonth(2026, Month.JULY) + + private fun allDay(from: LocalDate, toInclusive: LocalDate, id: Long = 1L) = EventInstance( + instanceId = id, + eventId = id, + calendarId = 1L, + title = "A", + start = from.atTime(0, 0).toInstant(TimeZone.UTC), + end = toInclusive.plus(1, DateTimeUnit.DAY).atTime(0, 0).toInstant(TimeZone.UTC), + isAllDay = true, + color = 0xFF445566.toInt(), + location = null, + ) + + private fun timed(date: LocalDate, hour: Int, id: Long = 2L) = EventInstance( + instanceId = id, + eventId = id, + calendarId = 1L, + title = "T", + start = date.atTime(hour, 0).toInstant(zone), + end = date.atTime(hour + 1, 0).toInstant(zone), + isAllDay = false, + color = 0xFF112233.toInt(), + location = null, + ) + + /** July 2026 starts on a Wednesday, so its first Monday-anchored row is Jun 29 – Jul 5. */ + private fun firstRowOfJuly(events: List) = + clipWeekToMonth( + layoutMonthWeeks(jul26, DayOfWeek.MONDAY, events, zone).first(), + jul26, + ) + + @Test + fun `the row keeps all seven days so the columns don't shift`() { + val week = firstRowOfJuly(emptyList()) + assertThat(week.days).hasSize(7) + assertThat(week.days.first()).isEqualTo(LocalDate(2026, 6, 29)) + } + + @Test + fun `the neighbour month's events are dropped`() { + val june = timed(LocalDate(2026, 6, 30), 9) + val july = timed(LocalDate(2026, 7, 1), 9, id = 3L) + val week = firstRowOfJuly(listOf(june, july)) + + assertThat(week.timedByDay.keys).doesNotContain(LocalDate(2026, 6, 30)) + assertThat(week.timedByDay[LocalDate(2026, 7, 1)]).containsExactly(july) + assertThat(week.countByDay[LocalDate(2026, 6, 30)]).isNull() + assertThat(week.countByDay[LocalDate(2026, 7, 1)]).isEqualTo(1) + } + + @Test + fun `a bar reaching in from the previous month is cut back to the 1st`() { + // Jun 29 – Jul 2: columns 0..3 unclipped, 2..3 once July owns the row. + val week = firstRowOfJuly(listOf(allDay(LocalDate(2026, 6, 29), LocalDate(2026, 7, 2)))) + + val span = week.spans.single() + assertThat(span.startCol).isEqualTo(2) // Wednesday the 1st + assertThat(span.endCol).isEqualTo(3) + // Flat cap on the cut side: the event continues out of this block. + assertThat(span.continuesLeft).isTrue() + assertThat(span.continuesRight).isFalse() + } + + @Test + fun `a bar living entirely in the neighbour month disappears`() { + val week = firstRowOfJuly(listOf(allDay(LocalDate(2026, 6, 29), LocalDate(2026, 6, 30)))) + assertThat(week.spans).isEmpty() + } + + @Test + fun `a bar reaching out into the next month is cut at the last day`() { + // Jul 29 – Aug 2 sits in July's final row (Jul 27 – Aug 2). + val weeks = layoutMonthWeeks( + jul26, + DayOfWeek.MONDAY, + listOf(allDay(LocalDate(2026, 7, 29), LocalDate(2026, 8, 2))), + zone, + ) + val span = clipWeekToMonth(weeks.last(), jul26).spans.single() + assertThat(span.startCol).isEqualTo(2) // Wednesday the 29th + assertThat(span.endCol).isEqualTo(4) // Friday the 31st + assertThat(span.continuesRight).isTrue() + assertThat(span.continuesLeft).isFalse() + } + + @Test + fun `a row wholly inside the month is untouched`() { + val mid = layoutMonthWeeks( + jul26, + DayOfWeek.MONDAY, + listOf(timed(LocalDate(2026, 7, 8), 9), allDay(LocalDate(2026, 7, 7), LocalDate(2026, 7, 9))), + zone, + )[1] + assertThat(clipWeekToMonth(mid, jul26)).isEqualTo(mid) + } +} diff --git a/app/src/test/java/de/jeanlucmakiola/calendula/ui/month/ContinuousMonthIndexTest.kt b/app/src/test/java/de/jeanlucmakiola/calendula/ui/month/ContinuousMonthIndexTest.kt new file mode 100644 index 0000000..22e0529 --- /dev/null +++ b/app/src/test/java/de/jeanlucmakiola/calendula/ui/month/ContinuousMonthIndexTest.kt @@ -0,0 +1,138 @@ +package de.jeanlucmakiola.calendula.ui.month + +import com.google.common.truth.Truth.assertThat +import kotlinx.datetime.DayOfWeek +import kotlinx.datetime.LocalDate +import kotlinx.datetime.Month +import kotlinx.datetime.TimeZone +import kotlinx.datetime.YearMonth +import org.junit.jupiter.api.Test + +/** + * The continuous grid's coordinate space. Every block's identity — and the + * LazyColumn items it maps to — hangs off this arithmetic, so it gets its own + * tests rather than being exercised only through the UI. + */ +class ContinuousMonthIndexTest { + + private val jun26 = YearMonth(2026, Month.JUNE) + + @Test + fun `consecutive months are consecutive indices`() { + val jun = monthIndexOf(jun26) + assertThat(monthIndexOf(YearMonth(2026, Month.JULY))).isEqualTo(jun + 1) + assertThat(monthIndexOf(YearMonth(2026, Month.MAY))).isEqualTo(jun - 1) + // And across the year boundary. + assertThat(monthIndexOf(YearMonth(2027, Month.JANUARY))) + .isEqualTo(monthIndexOf(YearMonth(2026, Month.DECEMBER)) + 1) + } + + @Test + fun `index round-trips back to its month`() { + listOf( + YearMonth(1900, Month.JANUARY), + jun26, + YearMonth(2026, Month.DECEMBER), + YearMonth(2100, Month.DECEMBER), + ).forEach { ym -> + assertThat(yearMonthForIndex(monthIndexOf(ym))).isEqualTo(ym) + } + } + + @Test + fun `indices are non-negative and span 1900 through 2100`() { + assertThat(monthIndexOf(YearMonth(1900, Month.JANUARY))).isEqualTo(0) + assertThat(monthIndexOf(jun26)).isGreaterThan(0) + assertThat(monthIndexOf(YearMonth(2100, Month.DECEMBER))) + .isEqualTo(continuousMonthCount() - 1) + assertThat(continuousMonthCount()).isEqualTo(201 * 12) + } + + @Test + fun `a month maps to its header item and back`() { + val index = monthIndexOf(jun26) + val item = itemIndexForMonth(index) + // Both of a month's items — its sticky header and the block under it — + // resolve back to the same month, so a title read off the first visible + // item is right wherever the viewport sits inside the block. + assertThat(monthIndexForItem(item)).isEqualTo(index) + assertThat(monthIndexForItem(item + 1)).isEqualTo(index) + assertThat(monthIndexForItem(item + 2)).isEqualTo(index + 1) + } + + @Test + fun `week rows follow the month's shape and its week start`() { + // June 2026 starts on a Monday: 30 days → 5 rows either way. + assertThat(weekRowsInMonth(jun26, DayOfWeek.MONDAY)).isEqualTo(5) + assertThat(weekRowsInMonth(jun26, DayOfWeek.SUNDAY)).isEqualTo(5) + // February 2026 starts on a Sunday: a Sunday-anchored grid fits it in 4 + // rows, a Monday-anchored one needs 5 — the block's height moves with the + // preference even though its position in the list doesn't. + val feb26 = YearMonth(2026, Month.FEBRUARY) + assertThat(weekRowsInMonth(feb26, DayOfWeek.SUNDAY)).isEqualTo(4) + assertThat(weekRowsInMonth(feb26, DayOfWeek.MONDAY)).isEqualTo(5) + // February 2021 starts on a Monday and is 28 days → exactly 4. + assertThat(weekRowsInMonth(YearMonth(2021, Month.FEBRUARY), DayOfWeek.MONDAY)) + .isEqualTo(4) + } + + @Test + fun `the placeholder block is the size the loaded one will be`() { + // Otherwise scrolling across an unloaded month jumps when its data lands. + (0 until 24).forEach { offset -> + val ym = yearMonthForIndex(monthIndexOf(jun26) + offset) + DayOfWeek.entries.forEach { ws -> + assertThat(weekRowsInMonth(ym, ws)) + .isEqualTo(layoutMonthWeeks(ym, ws, emptyList(), TimeZone.UTC).size) + } + } + } + + @Test + fun `firstOfMonth is the 1st`() { + assertThat(firstOfMonth(jun26)).isEqualTo(LocalDate(2026, 6, 1)) + } + + @Test + fun `a window comfortably around the visible range is kept`() { + assertThat(nextLoadWindow(loaded = 0..100, firstVisible = 40, lastVisible = 45)).isNull() + } + + @Test + fun `nearing a loaded edge widens the window around the visible range`() { + // Within a month of the top edge → reload, padded on both sides. + assertThat(nextLoadWindow(loaded = 0..100, firstVisible = 1, lastVisible = 2)) + .isEqualTo(-3..6) + + assertThat(nextLoadWindow(loaded = 0..100, firstVisible = 98, lastVisible = 100)) + .isEqualTo(94..104) + } + + @Test + fun `a jump far outside the window reloads around the destination`() { + assertThat(nextLoadWindow(loaded = 0..100, firstVisible = 500, lastVisible = 501)) + .isEqualTo(496..505) + } + + @Test + fun `the reloaded window always clears the trigger it just crossed`() { + // Otherwise every scroll frame would re-trigger a query. + val window = nextLoadWindow(loaded = 0..100, firstVisible = 1, lastVisible = 2)!! + assertThat(nextLoadWindow(window, firstVisible = 1, lastVisible = 2)).isNull() + } + + @Test + fun `the split selection follows the month, landing on today when it's there`() { + val today = LocalDate(2026, 6, 10) + assertThat(selectionForMonth(jun26, today)).isEqualTo(today) + } + + @Test + fun `the split selection falls to the 1st of any other month`() { + val today = LocalDate(2026, 6, 10) + assertThat(selectionForMonth(YearMonth(2026, Month.JULY), today)) + .isEqualTo(LocalDate(2026, 7, 1)) + assertThat(selectionForMonth(YearMonth(2025, Month.JUNE), today)) + .isEqualTo(LocalDate(2025, 6, 1)) + } +} diff --git a/app/src/test/java/de/jeanlucmakiola/calendula/ui/month/ContinuousWeekIndexTest.kt b/app/src/test/java/de/jeanlucmakiola/calendula/ui/month/ContinuousWeekIndexTest.kt deleted file mode 100644 index 2b17915..0000000 --- a/app/src/test/java/de/jeanlucmakiola/calendula/ui/month/ContinuousWeekIndexTest.kt +++ /dev/null @@ -1,121 +0,0 @@ -package de.jeanlucmakiola.calendula.ui.month - -import com.google.common.truth.Truth.assertThat -import kotlinx.datetime.DateTimeUnit -import kotlinx.datetime.DayOfWeek -import kotlinx.datetime.LocalDate -import kotlinx.datetime.YearMonth -import kotlinx.datetime.plus -import org.junit.jupiter.api.Test - -/** - * The continuous grid's coordinate space. Every row's identity — and the - * LazyColumn item it maps to — hangs off this arithmetic, so it gets its own - * tests rather than being exercised only through the UI. - */ -class ContinuousWeekIndexTest { - - // 2026-06-08 is a Monday; 2026-06-10 the Wednesday of the same week. - private val mon = LocalDate(2026, 6, 8) - private val wed = LocalDate(2026, 6, 10) - - @Test - fun `every day of a week shares one index`() { - val indices = (0..6).map { weekIndexOf(mon.plus(it, DateTimeUnit.DAY), DayOfWeek.MONDAY) } - assertThat(indices.toSet()).hasSize(1) - } - - @Test - fun `consecutive weeks are consecutive indices`() { - val a = weekIndexOf(mon, DayOfWeek.MONDAY) - val b = weekIndexOf(mon.plus(7, DateTimeUnit.DAY), DayOfWeek.MONDAY) - val back = weekIndexOf(mon.plus(-7, DateTimeUnit.DAY), DayOfWeek.MONDAY) - assertThat(b).isEqualTo(a + 1) - assertThat(back).isEqualTo(a - 1) - } - - @Test - fun `index round-trips back to the week's first day`() { - DayOfWeek.entries.forEach { ws -> - val index = weekIndexOf(wed, ws) - assertThat(weekStartForIndex(index, ws)).isEqualTo(wed.startOfGridWeek(ws)) - } - } - - @Test - fun `the week start shifts which week a boundary day belongs to`() { - // Sunday the 14th closes the Monday-anchored week but opens the Sunday one. - val sun = LocalDate(2026, 6, 14) - assertThat(weekIndexOf(sun, DayOfWeek.MONDAY)) - .isEqualTo(weekIndexOf(mon, DayOfWeek.MONDAY)) - assertThat(weekIndexOf(sun, DayOfWeek.SUNDAY)) - .isEqualTo(weekIndexOf(sun.plus(1, DateTimeUnit.DAY), DayOfWeek.SUNDAY)) - } - - @Test - fun `indices are non-negative across the supported span`() { - // The epoch sits before any date the grid scrolls to, so item indices and - // week indices stay the same number — no offset to reconcile. - DayOfWeek.entries.forEach { ws -> - assertThat(weekIndexOf(LocalDate(1900, 1, 1), ws)).isAtLeast(0) - assertThat(weekIndexOf(LocalDate(2026, 6, 8), ws)).isGreaterThan(0) - } - } - - @Test - fun `the list spans 1900 through 2100`() { - DayOfWeek.entries.forEach { ws -> - val count = continuousWeekCount(ws) - assertThat(weekIndexOf(LocalDate(2100, 12, 31), ws)).isLessThan(count) - assertThat(weekIndexOf(LocalDate(2026, 6, 8), ws)).isLessThan(count) - // ~200 years of weeks, give or take the anchor. - assertThat(count).isIn(10_400..10_500) - } - } - - @Test - fun `a window comfortably around the visible range is kept`() { - assertThat(nextLoadWindow(loaded = 0..100, firstVisible = 40, lastVisible = 45)).isNull() - } - - @Test - fun `nearing a loaded edge widens the window around the visible range`() { - // Within four weeks of the top edge → reload, padded on both sides. - val widened = nextLoadWindow(loaded = 0..100, firstVisible = 2, lastVisible = 7) - assertThat(widened).isEqualTo(-10..19) - - val atBottom = nextLoadWindow(loaded = 0..100, firstVisible = 94, lastVisible = 99) - assertThat(atBottom).isEqualTo(82..111) - } - - @Test - fun `a jump far outside the window reloads around the destination`() { - assertThat(nextLoadWindow(loaded = 0..100, firstVisible = 500, lastVisible = 505)) - .isEqualTo(488..517) - } - - @Test - fun `the reloaded window always clears the trigger it just crossed`() { - // Otherwise every scroll frame would re-trigger a query. - var loaded = 0..100 - val window = nextLoadWindow(loaded, firstVisible = 2, lastVisible = 7)!! - loaded = window - assertThat(nextLoadWindow(loaded, firstVisible = 2, lastVisible = 7)).isNull() - } - - @Test - fun `the split selection follows the month, landing on today when it's there`() { - val today = LocalDate(2026, 6, 10) - assertThat(selectionForMonth(YearMonth(2026, kotlinx.datetime.Month.JUNE), today)) - .isEqualTo(today) - } - - @Test - fun `the split selection falls to the 1st of any other month`() { - val today = LocalDate(2026, 6, 10) - assertThat(selectionForMonth(YearMonth(2026, kotlinx.datetime.Month.JULY), today)) - .isEqualTo(LocalDate(2026, 7, 1)) - assertThat(selectionForMonth(YearMonth(2025, kotlinx.datetime.Month.JUNE), today)) - .isEqualTo(LocalDate(2025, 6, 1)) - } -} From ee9d65068dff77e319d7b34170806ddfbe7bb5e3 Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Mon, 20 Jul 2026 17:59:53 +0200 Subject: [PATCH 44/78] fix(month): don't drag the continuous window back to 1900 (#38) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The continuous grid failed outright with "Could not read the calendar" the moment it opened. Before the first layout pass LazyListState reports no visible items, and the screen defaulted that to item 0 — claiming January 1900 was on screen. The load window widened around it, its pad ran past the epoch to index -4, and yearMonthForIndex indexed Month.entries out of bounds; the flow's catch turned the crash into the generic provider failure. Week indexing never showed this: index 0 was a real week, so a bogus report only cost one wasted query. - The visible-range report is skipped entirely while the list has no items, rather than standing in a default for them. - clampMonthWindow holds any window inside 1900–2100, so a pad at either end can't produce an index with no month behind it. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../calendula/ui/month/MonthScreen.kt | 13 +++++++++++-- .../calendula/ui/month/MonthViewModel.kt | 13 ++++++++++++- .../ui/month/ContinuousMonthIndexTest.kt | 19 +++++++++++++++++++ 3 files changed, 42 insertions(+), 3 deletions(-) diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthScreen.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthScreen.kt index 40474b5..a45ab09 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthScreen.kt @@ -101,6 +101,7 @@ import de.jeanlucmakiola.calendula.ui.common.rememberCalendarSlideSpec import de.jeanlucmakiola.calendula.ui.common.next import de.jeanlucmakiola.floret.time.isoWeekNumber import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.filterNotNull import kotlinx.coroutines.launch import kotlinx.datetime.DayOfWeek import kotlinx.datetime.LocalDate @@ -176,10 +177,18 @@ fun MonthScreen( LaunchedEffect(listState, continuous) { if (!continuous) return@LaunchedEffect snapshotFlow { + // Empty before the first layout pass — and reporting a default of 0 + // for it would claim January 1900 is on screen and drag the whole + // window back there. val visible = listState.layoutInfo.visibleItemsInfo - monthIndexForItem(visible.firstOrNull()?.index ?: 0) to - monthIndexForItem(visible.lastOrNull()?.index ?: 0) + if (visible.isEmpty()) { + null + } else { + monthIndexForItem(visible.first().index) to + monthIndexForItem(visible.last().index) + } } + .filterNotNull() .distinctUntilChanged() .collect { (first, last) -> viewModel.onVisibleMonthsChanged(first, last) } } 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 dd3e64b..d94d4b5 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 @@ -148,7 +148,8 @@ class MonthViewModel @Inject constructor( * close enough to a loaded edge to warrant a wider query. */ fun onVisibleMonthsChanged(firstIndex: Int, lastIndex: Int) { - nextLoadWindow(_loadedMonths.value, firstIndex, lastIndex)?.let { _loadedMonths.value = it } + nextLoadWindow(_loadedMonths.value, firstIndex, lastIndex) + ?.let { _loadedMonths.value = clampMonthWindow(it) } } private fun buildContinuousState( @@ -402,6 +403,16 @@ internal fun itemIndexForMonth(monthIndex: Int): Int = monthIndex * 2 internal fun monthIndexForItem(itemIndex: Int): Int = itemIndex / 2 +/** + * Hold a load window inside the months that actually exist. The pad + * [nextLoadWindow] adds can otherwise push an index past either end of the + * 1900–2100 span, and [yearMonthForIndex] has no month to hand back for one. + */ +internal fun clampMonthWindow(window: IntRange): IntRange { + val last = continuousMonthCount() - 1 + return window.first.coerceIn(0, last)..window.last.coerceIn(0, last) +} + internal fun firstOfMonth(ym: YearMonth): LocalDate = LocalDate(ym.year, ym.month, 1) /** How many week rows [ym] needs under [weekStart] — 4 (a non-leap February), 5 or 6. */ diff --git a/app/src/test/java/de/jeanlucmakiola/calendula/ui/month/ContinuousMonthIndexTest.kt b/app/src/test/java/de/jeanlucmakiola/calendula/ui/month/ContinuousMonthIndexTest.kt index 22e0529..c637c3d 100644 --- a/app/src/test/java/de/jeanlucmakiola/calendula/ui/month/ContinuousMonthIndexTest.kt +++ b/app/src/test/java/de/jeanlucmakiola/calendula/ui/month/ContinuousMonthIndexTest.kt @@ -88,6 +88,25 @@ class ContinuousMonthIndexTest { } } + @Test + fun `a window is clamped to the months that exist`() { + // The regression this guards: an empty visible-items list reported as + // "month 0", whose padded window ran to -4 — and yearMonthForIndex has + // no month for that, so the whole grid failed with "couldn't read". + assertThat(clampMonthWindow(-4..4)).isEqualTo(0..4) + val last = continuousMonthCount() - 1 + assertThat(clampMonthWindow((last - 2)..(last + 4))).isEqualTo((last - 2)..last) + assertThat(clampMonthWindow(10..20)).isEqualTo(10..20) + } + + @Test + fun `every index a clamped window can hold resolves to a month`() { + listOf(clampMonthWindow(-4..4), clampMonthWindow((continuousMonthCount() - 1)..(continuousMonthCount() + 3))) + .forEach { window -> + window.forEach { index -> yearMonthForIndex(index) } + } + } + @Test fun `firstOfMonth is the 1st`() { assertThat(firstOfMonth(jun26)).isEqualTo(LocalDate(2026, 6, 1)) From 24b005126eaf30b3a865ac9150ee9e84383fead8 Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Mon, 20 Jul 2026 18:10:54 +0200 Subject: [PATCH 45/78] feat(month): add the Dense style, rule under the Continuous header (#38) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Continuous's month blocks make the boundaries legible, but the seams are exactly what someone who wants a dense calendar doesn't want. Both are now offered: - **Dense** is the old flowing layout, kept rather than replaced — one uninterrupted stream of weeks with months running into each other, the 1st naming its month, and the top bar keeping the full month title (there is no sticky header to defer to). - Continuous gains a **rule under its month label**, closing the header off against the grid. On trial: it comes out again if it doesn't earn its place. One provider query serves both. `ContinuousMonthUiState.Success` now carries the same loaded window laid out twice — `monthsByIndex` clipped into blocks, `weeksByIndex` left whole — rather than standing up a second flow and querying the same range again. Both styles report *months* as they scroll, so the window hysteresis is shared; `weekWindowFor` maps a month window onto the Dense rows it covers, widened a week each side so boundary rows don't flicker. The list state is keyed on style *and* week start: Dense indexes by week, which the week-start preference moves, while Continuous indexes by month, which it doesn't — carrying an offset between the two would land somewhere arbitrary. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../calendula/ui/month/MonthScreen.kt | 225 ++++++++++++++---- .../calendula/ui/month/MonthStylePreview.kt | 25 +- .../calendula/ui/month/MonthUiState.kt | 14 +- .../calendula/ui/month/MonthViewModel.kt | 56 +++++ .../calendula/ui/month/MonthViewStyle.kt | 19 +- app/src/main/res/values/strings.xml | 2 + .../ui/month/ContinuousMonthIndexTest.kt | 53 +++++ 7 files changed, 339 insertions(+), 55 deletions(-) diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthScreen.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthScreen.kt index a45ab09..cbca6d4 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthScreen.kt @@ -24,6 +24,7 @@ import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyListState import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.selection.selectable import androidx.compose.foundation.shape.CircleShape @@ -48,6 +49,7 @@ import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue +import androidx.compose.runtime.key import androidx.compose.runtime.snapshotFlow import androidx.compose.runtime.mutableFloatStateOf import androidx.compose.runtime.mutableIntStateOf @@ -103,9 +105,11 @@ import de.jeanlucmakiola.floret.time.isoWeekNumber import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.filterNotNull import kotlinx.coroutines.launch +import kotlinx.datetime.DateTimeUnit import kotlinx.datetime.DayOfWeek import kotlinx.datetime.LocalDate import kotlinx.datetime.TimeZone +import kotlinx.datetime.plus import kotlinx.datetime.YearMonth import kotlinx.datetime.toJavaLocalDate import kotlinx.datetime.toLocalDateTime @@ -149,42 +153,61 @@ fun MonthScreen( val drawerState = rememberDrawerState(DrawerValue.Closed) val scope = rememberCoroutineScope() - val continuous = viewStyle == MonthViewStyle.Continuous + val scrolling = viewStyle.isScrolling + val dense = viewStyle == MonthViewStyle.Dense // Today, from whichever state is driving; the clock only covers the first // frame, before either has loaded. val today = when { - continuous -> (continuousState as? ContinuousMonthUiState.Success)?.today + scrolling -> (continuousState as? ContinuousMonthUiState.Success)?.today else -> (state as? MonthUiState.Success)?.today } ?: Clock.System.now().toLocalDateTime(TimeZone.currentSystemDefault()).date - // Month indices don't move with the week-start preference, so unlike the - // week-indexed stream this replaced, the list state survives a change to it. - val listState = rememberLazyListState( - initialFirstVisibleItemIndex = itemIndexForMonth(monthIndexOf(month)), - ) - - // Whichever month's block the top of the viewport is in. Its sticky header - // is the first visible item for all but the last sliver of a block, so this - // flips exactly when the header does. - val visibleMonth by remember { - derivedStateOf { yearMonthForIndex(monthIndexForItem(listState.firstVisibleItemIndex)) } + // The two scrolling styles index their items differently — Continuous by + // month (which the week start can't move), Dense by week (which it can) — so + // the list state is rebuilt when either changes rather than carrying an + // offset into a coordinate space that no longer means the same thing. + val listState = key(viewStyle, weekStart) { + rememberLazyListState( + initialFirstVisibleItemIndex = if (dense) { + weekIndexOf(LocalDate(month.year, month.month, 1), weekStart) + } else { + itemIndexForMonth(monthIndexOf(month)) + }, + ) } - val titleMonth = if (continuous) visibleMonth else month - // Feed the visible range back so the sliding data window can follow. The - // view model ignores everything that doesn't near a loaded edge. - LaunchedEffect(listState, continuous) { - if (!continuous) return@LaunchedEffect + // Which month the viewport is showing. Continuous reads it off the block + // whose sticky header is up; Dense has no blocks, so it takes the month the + // row below the top edge mostly sits in, which flips as that month's first + // full week comes into view. + val visibleMonth by remember(dense, weekStart) { + derivedStateOf { + if (dense) { + val midweek = weekStartForIndex(listState.firstVisibleItemIndex + 1, weekStart) + .plus(3, DateTimeUnit.DAY) + YearMonth(midweek.year, midweek.month) + } else { + yearMonthForIndex(monthIndexForItem(listState.firstVisibleItemIndex)) + } + } + } + val titleMonth = if (scrolling) visibleMonth else month + + // Feed the visible range back so the sliding data window can follow. Both + // styles report *months*, whatever they index by — one window serves both. + LaunchedEffect(listState, scrolling, dense, weekStart) { + if (!scrolling) return@LaunchedEffect snapshotFlow { // Empty before the first layout pass — and reporting a default of 0 // for it would claim January 1900 is on screen and drag the whole // window back there. val visible = listState.layoutInfo.visibleItemsInfo - if (visible.isEmpty()) { - null - } else { - monthIndexForItem(visible.first().index) to + when { + visible.isEmpty() -> null + dense -> monthIndexForWeek(visible.first().index, weekStart) to + monthIndexForWeek(visible.last().index, weekStart) + else -> monthIndexForItem(visible.first().index) to monthIndexForItem(visible.last().index) } } @@ -195,10 +218,11 @@ fun MonthScreen( val isOnCurrentMonth = titleMonth == YearMonth(today.year, today.month) - // The continuous style names the month on the block's own sticky header, so - // the bar carries the year instead of repeating it two lines further down. + // Continuous names the month on the block's own sticky header, so the bar + // carries the year instead of repeating it two lines further down. Dense has + // no header to defer to, so it keeps the full title. val locale = currentLocale() - val topBarTitle = if (continuous) { + val topBarTitle = if (viewStyle == MonthViewStyle.Continuous) { titleMonth.year.toString() } else { formatMonthTitle(titleMonth, locale, currentYear = today.year) @@ -219,10 +243,11 @@ fun MonthScreen( // (back), viewing the past → from the right (forward). The continuous stream // scrolls to today's week instead — there is nothing to slide. val jumpToToday = { - if (continuous) { + if (scrolling) { scope.launch { listState.animateScrollToItem( - itemIndexForMonth(monthIndexOf(YearMonth(today.year, today.month))), + if (dense) weekIndexOf(today, weekStart) + else itemIndexForMonth(monthIndexOf(YearMonth(today.year, today.month))), ) } Unit @@ -237,10 +262,11 @@ fun MonthScreen( } // Drawer jump-to-date: slide from the side the target month lies on. val jumpToDate: (LocalDate) -> Unit = { target -> - if (continuous) { + if (scrolling) { scope.launch { listState.animateScrollToItem( - itemIndexForMonth(monthIndexOf(YearMonth(target.year, target.month))), + if (dense) weekIndexOf(LocalDate(target.year, target.month, 1), weekStart) + else itemIndexForMonth(monthIndexOf(YearMonth(target.year, target.month))), ) } } else { @@ -310,10 +336,11 @@ fun MonthScreen( ) { WeekdayHeader(weekStart = weekStart, showWeekNumbers = showWeekNumbers) CompositionLocalProvider(LocalDimCutoff provides dimCutoff) { - if (continuous) { + if (scrolling) { ContinuousMonthContent( state = continuousState, listState = listState, + dense = dense, showWeekNumbers = showWeekNumbers, onRetry = jumpToToday, onOpenDay = onOpenDay, @@ -389,14 +416,15 @@ private fun MonthContent( } /** - * Continuous style content. No swipe detector and no [AnimatedContent]: vertical - * scrolling owns the gesture, and the list never swaps wholesale — it keeps - * scrolling while the window behind it reloads. + * Content for both scrolling styles. No swipe detector and no [AnimatedContent]: + * vertical scrolling owns the gesture, and the list never swaps wholesale — it + * keeps scrolling while the window behind it reloads. */ @Composable private fun ContinuousMonthContent( state: ContinuousMonthUiState, listState: LazyListState, + dense: Boolean, showWeekNumbers: Boolean, onRetry: () -> Unit, onOpenDay: (LocalDate) -> Unit, @@ -405,12 +433,21 @@ private fun ContinuousMonthContent( ContinuousMonthUiState.Loading -> MonthGridLoading() is ContinuousMonthUiState.Failure -> CalendarFailure(reason = state.reason, onRetry = onRetry) - is ContinuousMonthUiState.Success -> ContinuousMonthGrid( - state = state, - listState = listState, - showWeekNumbers = showWeekNumbers, - onOpenDay = onOpenDay, - ) + is ContinuousMonthUiState.Success -> if (dense) { + DenseMonthGrid( + state = state, + listState = listState, + showWeekNumbers = showWeekNumbers, + onOpenDay = onOpenDay, + ) + } else { + ContinuousMonthGrid( + state = state, + listState = listState, + showWeekNumbers = showWeekNumbers, + onOpenDay = onOpenDay, + ) + } } } @@ -648,22 +685,85 @@ private fun ContinuousMonthBlock( /** * The month label that pins above its block. Opaque `surface` so the rows scroll * under it cleanly, and generous vertical padding — the whitespace around the - * label is what makes each month read as its own section. + * label is what makes each month read as its own section. The rule beneath it + * closes the header off against the grid; it is the one part of this layout the + * user is still deciding on. */ @Composable private fun ContinuousMonthHeader(month: YearMonth, currentYear: Int, isCurrent: Boolean) { val locale = currentLocale() - Text( - text = formatMonthTitle(month, locale, currentYear), - style = MaterialTheme.typography.titleMedium, - color = if (isCurrent) MaterialTheme.colorScheme.primary - else MaterialTheme.colorScheme.onSurface, + Column( modifier = Modifier .fillMaxWidth() .background(MaterialTheme.colorScheme.surface) - .padding(horizontal = 12.dp) - .padding(top = 20.dp, bottom = 10.dp), - ) + .padding(top = 20.dp), + ) { + Text( + text = formatMonthTitle(month, locale, currentYear), + style = MaterialTheme.typography.titleMedium, + color = if (isCurrent) MaterialTheme.colorScheme.primary + else MaterialTheme.colorScheme.onSurface, + modifier = Modifier.padding(horizontal = 12.dp, vertical = 0.dp), + ) + HorizontalDivider( + color = MaterialTheme.colorScheme.outlineVariant, + modifier = Modifier.padding(top = 8.dp, bottom = 8.dp), + ) + } +} + +/** + * The Dense style (#38): one uninterrupted vertical stream of weeks, months + * flowing into each other. + * + * Kept alongside the block layout rather than replaced by it — the seams that + * make months legible are exactly what someone wanting a dense calendar doesn't + * want. There is no month boundary to dim across here, so every day reads as + * in-month and the 1st names its month: the only marker of where one ends. + * + * Indexed by absolute *week* number, so a row's identity never changes and there + * is no page seam to scroll across. Weeks the loaded window hasn't reached yet + * render as a skeleton and pull the window along behind them. + */ +@Composable +internal fun DenseMonthGrid( + state: ContinuousMonthUiState.Success, + listState: LazyListState, + showWeekNumbers: Boolean, + onOpenDay: (LocalDate) -> Unit, + modifier: Modifier = Modifier, +) { + val weekCount = remember(state.weekStart) { continuousWeekCount(state.weekStart) } + LazyColumn( + state = listState, + modifier = modifier + .fillMaxSize() + .padding(horizontal = 8.dp), + verticalArrangement = Arrangement.spacedBy(2.dp), + // Bottom inset clears the FAB stack so the last row stays tappable. + contentPadding = PaddingValues(top = 4.dp, bottom = 96.dp), + ) { + items(count = weekCount, key = { it }) { index -> + val week = state.weeksByIndex[index] + if (week == null) { + ContinuousWeekPlaceholder() + } else { + MonthWeekRow( + week = week, + today = state.today, + // Every day in the stream belongs to a month equally — there + // is no "other month" to recede here. + inMonth = { true }, + showWeekNumbers = showWeekNumbers, + onOpenDay = onOpenDay, + labelMonthOnFirst = true, + modifier = Modifier + .fillMaxWidth() + .height(CONTINUOUS_ROW_HEIGHT), + ) + } + } + } } /** @@ -970,6 +1070,7 @@ private fun MonthWeekRow( onOpenDay: (LocalDate) -> Unit, modifier: Modifier = Modifier, blankOutside: Boolean = false, + labelMonthOnFirst: Boolean = false, ) { val dark = isSystemInDarkTheme() val laneCount = (week.spans.maxOfOrNull { it.lane } ?: -1) + 1 @@ -1029,6 +1130,13 @@ private fun MonthWeekRow( date = d, isToday = d == today, inMonth = inMonth(d), + // Dense has no month boundaries to dim across and + // no header to name the month, so the 1st does it. + monthLabel = if (labelMonthOnFirst && d.day == 1) { + shortMonthName(d) + } else { + null + }, modifier = Modifier.weight(1f), ) } @@ -1167,6 +1275,7 @@ private fun DayNumberCell( isToday: Boolean, inMonth: Boolean, modifier: Modifier = Modifier, + monthLabel: String? = null, ) { Box( modifier = modifier.height(DAY_NUMBER_HEIGHT), @@ -1186,6 +1295,16 @@ private fun DayNumberCell( color = MaterialTheme.colorScheme.onPrimary, ) } + } else if (monthLabel != null) { + Text( + text = "$monthLabel ${date.day}", + style = MaterialTheme.typography.labelMedium, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.primary, + maxLines = 1, + overflow = TextOverflow.Visible, + softWrap = false, + ) } else { Text( text = date.day.toString(), @@ -1197,6 +1316,16 @@ private fun DayNumberCell( } } +/** Locale-short month name ("Jul", "Juli"), for the 1st in the Dense grid. */ +@Composable +private fun shortMonthName(date: LocalDate): String { + val locale = currentLocale() + return remember(date.month, locale) { + java.time.Month.of(date.month.ordinal + 1) + .getDisplayName(JavaTextStyle.SHORT, locale) + } +} + /** A filled event pill/bar — softened (or raw) fill, title clipped to one line. */ @Composable private fun MonthBar( diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthStylePreview.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthStylePreview.kt index d113cf3..c75a223 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthStylePreview.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthStylePreview.kt @@ -119,6 +119,18 @@ internal fun MonthStylePreview( showWeekNumbers = false, onOpenDay = {}, ) + MonthViewStyle.Dense -> DenseMonthGrid( + state = sample.continuous, + listState = rememberLazyListState( + // Start a week above today's, so the preview shows a + // stream running past the viewport rather than one + // beginning at its top edge. + initialFirstVisibleItemIndex = + weekIndexOf(today, weekStart) - 1, + ), + showWeekNumbers = false, + onOpenDay = {}, + ) MonthViewStyle.Split -> { SplitMonthGrid( state = sample.month, @@ -182,16 +194,23 @@ private fun sampleMonthState( zone = zone, ) - // This month and the next: enough for the preview to show a month block, the - // whitespace under it and the following month's header coming up. + // The month either side of this one: enough for Continuous to show a block, + // the whitespace under it and the next month's header coming up, and for + // Dense to have somewhere to scroll in both directions. val centre = monthIndexOf(ym) - val window = centre..(centre + 1) + val window = (centre - 1)..(centre + 1) val continuous = ContinuousMonthUiState.Success( today = today, monthsByIndex = window.associateWith { index -> val m = yearMonthForIndex(index) layoutMonthWeeks(m, weekStart, events, zone).map { clipWeekToMonth(it, m) } }, + weeksByIndex = weekWindowFor(window, weekStart).associateWith { index -> + val days = (0 until 7).map { + weekStartForIndex(index, weekStart).plus(it, DateTimeUnit.DAY) + } + layoutCalendarWeek(days, events, zone) + }, weekStart = weekStart, ) return SampleMonth(month, continuous) diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthUiState.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthUiState.kt index 91ff84a..564466b 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthUiState.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthUiState.kt @@ -46,8 +46,17 @@ data class MonthWeek( * its seven columns so the grid geometry never shifts, but the neighbour month's * dates are clipped out of it (see `clipWeekToMonth`) and render as blanks. * - * [monthsByIndex] holds only the loaded window; indices outside it render as - * placeholders until the window catches up. + * The same loaded window is served two ways, because the Dense style is the same + * scroll with the seams taken out and it would be wasteful to query the provider + * twice for one range: + * + * - [monthsByIndex] — the block layout, keyed by absolute month index, each week + * clipped to its own month. + * - [weeksByIndex] — the flowing layout, keyed by absolute week index, boundary + * weeks left carrying both months. + * + * Both hold only the loaded window; indices outside it render as placeholders + * until the window catches up. */ sealed interface ContinuousMonthUiState { data object Loading : ContinuousMonthUiState @@ -55,6 +64,7 @@ sealed interface ContinuousMonthUiState { data class Success( val today: LocalDate, val monthsByIndex: Map>, + val weeksByIndex: Map, val weekStart: DayOfWeek, ) : ContinuousMonthUiState } 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 d94d4b5..db574b5 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 @@ -30,6 +30,7 @@ import kotlinx.datetime.TimeZone import kotlinx.datetime.YearMonth import kotlinx.datetime.atStartOfDayIn import kotlinx.datetime.atTime +import kotlinx.datetime.daysUntil import kotlinx.datetime.minus import kotlinx.datetime.plus import kotlinx.datetime.toInstant @@ -165,9 +166,18 @@ class MonthViewModel @Inject constructor( val ym = yearMonthForIndex(index) layoutMonthWeeks(ym, weekStart, instances, zone).map { clipWeekToMonth(it, ym) } } + // The Dense style's rows, from the same query: every week the window's + // months touch, laid out whole rather than clipped to a month. + val weeks = weekWindowFor(window, weekStart).associateWith { index -> + val days = (0 until 7).map { + weekStartForIndex(index, weekStart).plus(it, DateTimeUnit.DAY) + } + layoutCalendarWeek(days, instances, zone) + } return ContinuousMonthUiState.Success( today = todayDate, monthsByIndex = months, + weeksByIndex = weeks, weekStart = weekStart, ) } @@ -415,6 +425,52 @@ internal fun clampMonthWindow(window: IntRange): IntRange { internal fun firstOfMonth(ym: YearMonth): LocalDate = LocalDate(ym.year, ym.month, 1) +/** + * The Dense style addresses *weeks* by absolute index instead: it has no month + * blocks to key by, and a row's identity has to survive the months around it + * scrolling past. Index 0 is the first week of 1900 under the active week-start, + * so — like the month space — every index is non-negative and the LazyColumn's + * item indices and week indices are the same number. + * + * Unlike month indices these *do* shift with the week-start preference, which is + * why the list state is keyed on it. + */ +private val WEEK_INDEX_EPOCH = LocalDate(1900, 1, 1) +private val WEEK_INDEX_END = LocalDate(2100, 12, 31) + +internal fun weekIndexOf(date: LocalDate, weekStart: DayOfWeek): Int { + val base = WEEK_INDEX_EPOCH.startOfGridWeek(weekStart) + return base.daysUntil(date.startOfGridWeek(weekStart)) / 7 +} + +internal fun weekStartForIndex(index: Int, weekStart: DayOfWeek): LocalDate = + WEEK_INDEX_EPOCH.startOfGridWeek(weekStart).plus(index * 7, DateTimeUnit.DAY) + +/** Total weeks the Dense grid scrolls through (1900 → 2100). */ +internal fun continuousWeekCount(weekStart: DayOfWeek): Int = + weekIndexOf(WEEK_INDEX_END, weekStart) + 1 + +/** Which month a Dense row belongs to, for reporting the visible range. */ +internal fun monthIndexForWeek(weekIndex: Int, weekStart: DayOfWeek): Int { + // The row's midweek day: a boundary week is reported as whichever month owns + // most of it, the same rule the title uses. + val midweek = weekStartForIndex(weekIndex, weekStart).plus(3, DateTimeUnit.DAY) + return monthIndexOf(YearMonth(midweek.year, midweek.month)) +} + +/** + * Every week index the months in [window] touch — the Dense rows one month-window + * load covers. Widened by a week at each end so the boundary rows either side are + * laid out too, rather than flickering as placeholders. + */ +internal fun weekWindowFor(window: IntRange, weekStart: DayOfWeek): IntRange { + val first = weekIndexOf(firstOfMonth(yearMonthForIndex(window.first)), weekStart) - 1 + val lastMonthEnd = firstOfMonth(yearMonthForIndex(window.last)) + .plus(1, DateTimeUnit.MONTH) + .minus(1, DateTimeUnit.DAY) + return first..(weekIndexOf(lastMonthEnd, weekStart) + 1) +} + /** How many week rows [ym] needs under [weekStart] — 4 (a non-leap February), 5 or 6. */ internal fun weekRowsInMonth(ym: YearMonth, weekStart: DayOfWeek): Int { val first = firstOfMonth(ym) diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthViewStyle.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthViewStyle.kt index 6b636a4..2f553c8 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthViewStyle.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthViewStyle.kt @@ -19,20 +19,34 @@ enum class MonthViewStyle { Paged, /** - * One uninterrupted vertical stream of weeks. Each date appears exactly once, - * which is the point — paging repeats a boundary week at both ends (#38). + * Months stacked into one vertical scroll, each a self-contained block under + * its own sticky header: a block shows only its own days, and whitespace + * separates it from the next (#38). */ Continuous, + /** + * [Continuous] with the seams taken out: one uninterrupted stream of weeks + * where months flow into each other. Each date appears exactly once — paging + * repeats a boundary week at both ends — and the 1st names its month, the + * only marker of where one ends. + */ + Dense, + /** Compact dots-only grid over a list of the selected day's events (#53). */ Split, } +/** Both vertically scrolling styles, which share a data window and a list state. */ +val MonthViewStyle.isScrolling: Boolean + get() = this == MonthViewStyle.Continuous || this == MonthViewStyle.Dense + @get:StringRes val MonthViewStyle.labelRes: Int get() = when (this) { MonthViewStyle.Paged -> R.string.month_style_paged MonthViewStyle.Continuous -> R.string.month_style_continuous + MonthViewStyle.Dense -> R.string.month_style_dense MonthViewStyle.Split -> R.string.month_style_split } @@ -41,5 +55,6 @@ val MonthViewStyle.descriptionRes: Int get() = when (this) { MonthViewStyle.Paged -> R.string.month_style_paged_summary MonthViewStyle.Continuous -> R.string.month_style_continuous_summary + MonthViewStyle.Dense -> R.string.month_style_dense_summary MonthViewStyle.Split -> R.string.month_style_split_summary } diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 5bd8dd6..bdd8be4 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -380,6 +380,8 @@ One month at a time. Swipe sideways to change month. Continuous Scroll up and down through the months. Each month keeps to itself under its own heading, with no days borrowed from its neighbours. + Dense + The same endless scroll with the seams taken out — one unbroken run of weeks, months flowing into each other. Split A compact grid with dots for events, and the day you tap listed underneath. Nothing scheduled diff --git a/app/src/test/java/de/jeanlucmakiola/calendula/ui/month/ContinuousMonthIndexTest.kt b/app/src/test/java/de/jeanlucmakiola/calendula/ui/month/ContinuousMonthIndexTest.kt index c637c3d..7abfc13 100644 --- a/app/src/test/java/de/jeanlucmakiola/calendula/ui/month/ContinuousMonthIndexTest.kt +++ b/app/src/test/java/de/jeanlucmakiola/calendula/ui/month/ContinuousMonthIndexTest.kt @@ -1,11 +1,14 @@ package de.jeanlucmakiola.calendula.ui.month import com.google.common.truth.Truth.assertThat +import kotlinx.datetime.DateTimeUnit import kotlinx.datetime.DayOfWeek import kotlinx.datetime.LocalDate import kotlinx.datetime.Month import kotlinx.datetime.TimeZone import kotlinx.datetime.YearMonth +import kotlinx.datetime.minus +import kotlinx.datetime.plus import org.junit.jupiter.api.Test /** @@ -107,6 +110,56 @@ class ContinuousMonthIndexTest { } } + @Test + fun `the dense week window covers every week its months touch`() { + // One month-window load has to lay out every Dense row those months + // appear in, boundary weeks included, or rows would flicker as + // placeholders while the data for them is already in hand. + DayOfWeek.entries.forEach { ws -> + val window = monthIndexOf(jun26)..monthIndexOf(YearMonth(2026, Month.AUGUST)) + val weeks = weekWindowFor(window, ws) + window.forEach { index -> + val ym = yearMonthForIndex(index) + val first = weekIndexOf(firstOfMonth(ym), ws) + val last = weekIndexOf(firstOfMonth(yearMonthForIndex(index + 1)).minus(1, DateTimeUnit.DAY), ws) + assertThat(weeks.contains(first)).isTrue() + assertThat(weeks.contains(last)).isTrue() + } + } + } + + @Test + fun `a dense row reports the month that owns most of it`() { + // June 29 – July 5, 2026 (Monday-anchored): four of its days are July's, + // so the window follows July rather than snapping back to June. + val boundary = weekIndexOf(LocalDate(2026, 6, 29), DayOfWeek.MONDAY) + assertThat(monthIndexForWeek(boundary, DayOfWeek.MONDAY)) + .isEqualTo(monthIndexOf(YearMonth(2026, Month.JULY))) + // A row wholly inside June reports June. + assertThat(monthIndexForWeek(weekIndexOf(LocalDate(2026, 6, 15), DayOfWeek.MONDAY), DayOfWeek.MONDAY)) + .isEqualTo(monthIndexOf(jun26)) + } + + @Test + fun `every day of a dense week shares one index`() { + val mon = LocalDate(2026, 6, 8) + val indices = (0..6).map { weekIndexOf(mon.plus(it, DateTimeUnit.DAY), DayOfWeek.MONDAY) } + assertThat(indices.toSet()).hasSize(1) + } + + @Test + fun `dense week indices round-trip and stay inside the list`() { + val wed = LocalDate(2026, 6, 10) + DayOfWeek.entries.forEach { ws -> + val index = weekIndexOf(wed, ws) + assertThat(weekStartForIndex(index, ws)).isEqualTo(wed.startOfGridWeek(ws)) + assertThat(weekIndexOf(LocalDate(1900, 1, 1), ws)).isAtLeast(0) + assertThat(index).isLessThan(continuousWeekCount(ws)) + assertThat(weekIndexOf(LocalDate(2100, 12, 31), ws)) + .isLessThan(continuousWeekCount(ws)) + } + } + @Test fun `firstOfMonth is the 1st`() { assertThat(firstOfMonth(jun26)).isEqualTo(LocalDate(2026, 6, 1)) From 95f4d28bbb5a57b72143b539deaec07beaeabd1a Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Mon, 20 Jul 2026 18:23:06 +0200 Subject: [PATCH 46/78] perf(month): open the scrolling styles on a small window and grow it (#38) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Opening Continuous made the first frame wait for nine months of recurrence expansion when only one was about to be looked at — a provider Instances query expands across its whole range, so the window's generosity was paid for up front, every time. The window now starts at one month either side of the visible one — about what the paged style costs — and each completed load reaches two months further in both directions until it spans eleven. The months a scroll can reach arrive while the first one is already on screen, and because a widening is triggered by the previous load landing rather than by a timer, the ladder can never outrun the provider. - The paged and split styles no longer run this query at all. The screen collects the flow whatever the style is set to, so until now every Month view opening paid for a window it would never draw. - The reload trigger is derived from the current pad instead of being fixed, and is held strictly inside it: a trigger at or beyond the pad would re-fire the moment its own reload landed. - The scrolling styles get their own skeleton — the layout they are about to become, at the same measurements, so arriving months replace it in place. It and the per-month placeholders now breathe, so a slow load reads as work rather than as an empty grid. Held still under reduced motion. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../calendula/ui/month/MonthScreen.kt | 83 +++++++++++++++- .../calendula/ui/month/MonthViewModel.kt | 97 +++++++++++++++---- .../ui/month/ContinuousMonthIndexTest.kt | 42 ++++++-- 3 files changed, 195 insertions(+), 27 deletions(-) diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthScreen.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthScreen.kt index cbca6d4..12ecb20 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthScreen.kt @@ -1,6 +1,11 @@ package de.jeanlucmakiola.calendula.ui.month import androidx.compose.animation.AnimatedContent +import androidx.compose.animation.core.RepeatMode +import androidx.compose.animation.core.animateFloat +import androidx.compose.animation.core.infiniteRepeatable +import androidx.compose.animation.core.rememberInfiniteTransition +import androidx.compose.animation.core.tween import androidx.compose.foundation.background import androidx.compose.foundation.border import androidx.compose.foundation.clickable @@ -430,7 +435,10 @@ private fun ContinuousMonthContent( onOpenDay: (LocalDate) -> Unit, ) { when (state) { - ContinuousMonthUiState.Loading -> MonthGridLoading() + // The scrolling styles get their own skeleton rather than the paged + // grid's: same row height, same header, so nothing reflows when the + // months land on top of it. + ContinuousMonthUiState.Loading -> ContinuousMonthSkeleton(dense = dense) is ContinuousMonthUiState.Failure -> CalendarFailure(reason = state.reason, onRetry = onRetry) is ContinuousMonthUiState.Success -> if (dense) { @@ -1037,6 +1045,7 @@ internal fun SplitDayPane( /** A week the sliding window hasn't loaded yet — same height, so nothing jumps. */ @Composable private fun ContinuousWeekPlaceholder() { + val pulse = rememberSkeletonPulse() Row( modifier = Modifier .fillMaxWidth() @@ -1048,12 +1057,84 @@ private fun ContinuousWeekPlaceholder() { .weight(1f) .fillMaxHeight() .padding(horizontal = CELL_GAP, vertical = 1.dp) + .alpha(pulse) .background(MaterialTheme.colorScheme.surfaceContainerLow, CELL_SHAPE), ) } } } +/** + * The first-frame skeleton for the scrolling styles: the layout they are about + * to become, at the same measurements, so the arriving months replace it in + * place instead of shifting everything. + * + * In practice the window is small enough that this is rarely on screen for long + * — it is there for the calendar big enough to make even one month's worth of + * recurrence expansion take a moment. + */ +@Composable +private fun ContinuousMonthSkeleton(dense: Boolean) { + val pulse = rememberSkeletonPulse() + Column( + modifier = Modifier + .fillMaxSize() + .padding(horizontal = 8.dp) + .clipToBounds(), + ) { + if (!dense) { + // Stand-in for the sticky month header, so the first rows start + // where they will once the real one is there. + Box( + modifier = Modifier + .padding(start = 4.dp, top = 20.dp, bottom = 8.dp) + .width(SKELETON_HEADER_WIDTH) + .height(SKELETON_HEADER_HEIGHT) + .alpha(pulse) + .background( + MaterialTheme.colorScheme.surfaceContainerHigh, + MaterialTheme.shapes.small, + ), + ) + HorizontalDivider( + color = MaterialTheme.colorScheme.outlineVariant, + modifier = Modifier.padding(bottom = 8.dp), + ) + } else { + Spacer(Modifier.height(4.dp)) + } + // More rows than a viewport holds; the clip takes the overflow. + repeat(6) { + ContinuousWeekPlaceholder() + Spacer(Modifier.height(2.dp)) + } + } +} + +private val SKELETON_HEADER_WIDTH = 128.dp +private val SKELETON_HEADER_HEIGHT = 20.dp + +/** + * The slow breath that tells a skeleton from an empty grid. Held at full opacity + * when the system asks for reduced motion — the placeholders still read as + * unfilled without it. + */ +@Composable +private fun rememberSkeletonPulse(): Float { + if (rememberReduceMotion()) return 1f + val transition = rememberInfiniteTransition(label = "skeleton") + val alpha by transition.animateFloat( + initialValue = 1f, + targetValue = 0.4f, + animationSpec = infiniteRepeatable( + animation = tween(durationMillis = 900), + repeatMode = RepeatMode.Reverse, + ), + label = "skeleton-alpha", + ) + return alpha +} + /** * One week of the grid. Bars (all-day / multi-day) are positioned absolutely so * a multi-day event is one connected bar across the columns; single-day timed 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 db574b5..52edd51 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 @@ -20,7 +20,9 @@ import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.catch import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.stateIn import kotlinx.datetime.DateTimeUnit import kotlinx.datetime.DayOfWeek @@ -101,23 +103,45 @@ class MonthViewModel @Inject constructor( initialValue = MonthUiState.Loading, ) - // --- Continuous style (#38) ------------------------------------------- + // --- Continuous + Dense styles (#38) ---------------------------------- // - // The continuous grid scrolls through every month there is, so it can't load - // one month at a time — it loads a sliding window of month indices around - // whatever is on screen. The window only moves when the visible range comes - // within WINDOW_EDGE months of a loaded edge, so a scroll re-queries - // occasionally rather than on every frame. + // These scroll through every month there is, so they can't load "a month" — + // they load a sliding window of month indices around whatever is on screen. + // The window only moves when the visible range comes within WINDOW_EDGE + // months of a loaded edge, so a scroll re-queries occasionally rather than + // on every frame. + // + // The window also *starts small and grows*. A provider Instances query + // expands recurrences across its whole range, so opening straight onto a + // year of months made the first frame wait for eleven months of expansion + // when only one was about to be looked at. It now opens on INITIAL_PAD + // months, and each completed load widens by GROWTH_STEP in both directions + // until MAX_PAD — the months you can reach by scrolling arrive while you're + // still looking at the first one. + + /** How far around the visible range we currently load; grows as loads land. */ + @Volatile + private var loadPad = INITIAL_PAD + + /** Last reported visible range, so a widening step can re-centre on it. */ + @Volatile + private var visibleMonths = monthIndexOf(YearMonth(todayDate.year, todayDate.month)) + .let { it..it } - // Seeded around today so the first frame has data. private val _loadedMonths = MutableStateFlow( monthIndexOf(YearMonth(todayDate.year, todayDate.month)) - .let { it - WINDOW_PAD..it + WINDOW_PAD }, + .let { clampMonthWindow(it - INITIAL_PAD..it + INITIAL_PAD) }, ) val continuousState: StateFlow = - combine(_loadedMonths, weekStart) { window, ws -> window to ws } - .flatMapLatest { (window, ws) -> + combine(_loadedMonths, weekStart, viewStyle) { window, ws, style -> + Triple(window, ws, style) + } + .flatMapLatest { (window, ws, style) -> + // Nothing to load for the paged and split styles — they have + // their own single-month flow, and querying a year of months + // behind them is pure waste. + if (!style.isScrolling) return@flatMapLatest flowOf(ContinuousMonthUiState.Loading) // Widened to whole grid weeks at both ends: a month block still // has to know about an event that starts in the boundary week's // clipped-off days, or a bar running into the block would vanish. @@ -135,6 +159,10 @@ class MonthViewModel @Inject constructor( buildContinuousState(window, ws, calendars, instances) } } + // A load landing is the cue to reach further out. Widening from here + // rather than on a timer means each step waits for the previous one, + // so the ladder can never outrun the provider. + .onEach { if (it is ContinuousMonthUiState.Success) widenLoadedWindow() } .catch { emit(ContinuousMonthUiState.Failure(FailureReason.ProviderUnavailable)) } .flowOn(io) .stateIn( @@ -149,10 +177,24 @@ class MonthViewModel @Inject constructor( * close enough to a loaded edge to warrant a wider query. */ fun onVisibleMonthsChanged(firstIndex: Int, lastIndex: Int) { - nextLoadWindow(_loadedMonths.value, firstIndex, lastIndex) + visibleMonths = firstIndex..lastIndex + nextLoadWindow(_loadedMonths.value, firstIndex, lastIndex, loadPad) ?.let { _loadedMonths.value = clampMonthWindow(it) } } + /** + * One rung up the ladder: reach [GROWTH_STEP] further in each direction, + * stopping at [MAX_PAD]. A no-op once there, so the provider notifications + * that re-emit the same window don't restart it. + */ + private fun widenLoadedWindow() { + if (loadPad >= MAX_PAD) return + loadPad = (loadPad + GROWTH_STEP).coerceAtMost(MAX_PAD) + _loadedMonths.value = clampMonthWindow( + visibleMonths.first - loadPad..visibleMonths.last + loadPad, + ) + } + private fun buildContinuousState( window: IntRange, weekStart: DayOfWeek, @@ -347,14 +389,25 @@ internal fun monthGridRange( } /** - * How many months the continuous window loads beyond the visible range, and how - * close the visible range may drift to a loaded edge before it reloads. The pad - * is generous relative to the trigger so a steady scroll crosses the trigger - * well before it would run out of laid-out months. + * The sliding window's shape. + * + * [INITIAL_PAD] is what the first frame waits for — one month either side of the + * visible one, so opening the view costs about what the paged style costs. Each + * completed load then reaches [GROWTH_STEP] further out until [MAX_PAD], filling + * in the months a scroll could reach while the first ones are already on screen. + * + * [WINDOW_EDGE] is how close the visible range may drift to a loaded edge before + * it reloads. It is always kept below the current pad — a trigger at or beyond + * the pad would re-fire the moment its own reload landed. */ -private const val WINDOW_PAD = 4 +private const val INITIAL_PAD = 1 +private const val GROWTH_STEP = 2 +private const val MAX_PAD = 5 private const val WINDOW_EDGE = 2 +/** The reload trigger for a given pad, held strictly inside it. */ +internal fun edgeForPad(pad: Int): Int = minOf(WINDOW_EDGE, pad - 1).coerceAtLeast(0) + /** * Which day the split style should select when the grid lands on [month]: * [today] when the month holds it, otherwise the 1st. Pure so the rule can be @@ -373,11 +426,17 @@ internal fun selectionForMonth(month: YearMonth, today: LocalDate): LocalDate = * Kept pure and separate from the view model so the hysteresis — the reason a * scroll doesn't re-query the provider on every frame — is testable on its own. */ -internal fun nextLoadWindow(loaded: IntRange, firstVisible: Int, lastVisible: Int): IntRange? { +internal fun nextLoadWindow( + loaded: IntRange, + firstVisible: Int, + lastVisible: Int, + pad: Int = MAX_PAD, +): IntRange? { + val edge = edgeForPad(pad) val comfortablyInside = - firstVisible - WINDOW_EDGE >= loaded.first && lastVisible + WINDOW_EDGE <= loaded.last + firstVisible - edge >= loaded.first && lastVisible + edge <= loaded.last if (comfortablyInside) return null - return (firstVisible - WINDOW_PAD)..(lastVisible + WINDOW_PAD) + return (firstVisible - pad)..(lastVisible + pad) } /** diff --git a/app/src/test/java/de/jeanlucmakiola/calendula/ui/month/ContinuousMonthIndexTest.kt b/app/src/test/java/de/jeanlucmakiola/calendula/ui/month/ContinuousMonthIndexTest.kt index 7abfc13..ef2c56b 100644 --- a/app/src/test/java/de/jeanlucmakiola/calendula/ui/month/ContinuousMonthIndexTest.kt +++ b/app/src/test/java/de/jeanlucmakiola/calendula/ui/month/ContinuousMonthIndexTest.kt @@ -172,25 +172,53 @@ class ContinuousMonthIndexTest { @Test fun `nearing a loaded edge widens the window around the visible range`() { - // Within a month of the top edge → reload, padded on both sides. + // Within two months of the top edge → reload, padded on both sides. assertThat(nextLoadWindow(loaded = 0..100, firstVisible = 1, lastVisible = 2)) - .isEqualTo(-3..6) + .isEqualTo(-4..7) assertThat(nextLoadWindow(loaded = 0..100, firstVisible = 98, lastVisible = 100)) - .isEqualTo(94..104) + .isEqualTo(93..105) } @Test fun `a jump far outside the window reloads around the destination`() { assertThat(nextLoadWindow(loaded = 0..100, firstVisible = 500, lastVisible = 501)) - .isEqualTo(496..505) + .isEqualTo(495..506) } @Test fun `the reloaded window always clears the trigger it just crossed`() { - // Otherwise every scroll frame would re-trigger a query. - val window = nextLoadWindow(loaded = 0..100, firstVisible = 1, lastVisible = 2)!! - assertThat(nextLoadWindow(window, firstVisible = 1, lastVisible = 2)).isNull() + // Otherwise every scroll frame would re-trigger a query — and with the + // window growing from a pad of 1, this has to hold at every rung of the + // ladder, not just the widest one. + (1..8).forEach { pad -> + val loaded = (10 - pad)..(10 + pad) + val past = 10 + pad + 1 + val widened = nextLoadWindow(loaded, past, past, pad)!! + assertThat(nextLoadWindow(widened, past, past, pad)).isNull() + } + } + + @Test + fun `the reload trigger stays inside the pad`() { + // A trigger at or beyond the pad would fire again the instant its own + // reload landed, and the window would query forever. + (1..8).forEach { pad -> assertThat(edgeForPad(pad)).isLessThan(pad) } + // A one-month window has no room for hysteresis: reload only on contact. + assertThat(edgeForPad(1)).isEqualTo(0) + assertThat(edgeForPad(0)).isEqualTo(0) + } + + @Test + fun `the smallest window reloads on crossing rather than nearing its edge`() { + // The first frame loads one month either side of today, which leaves no + // room to reload *before* the edge: at pad 1 the trigger is contact. + // Only momentary — the first completed load widens the pad to 3, which + // buys the usual head start back. + val initial = 10..12 + assertThat(nextLoadWindow(initial, firstVisible = 12, lastVisible = 12, pad = 1)).isNull() + assertThat(nextLoadWindow(initial, firstVisible = 13, lastVisible = 13, pad = 1)) + .isEqualTo(12..14) } @Test From b417900ddd99c54fb249a6c476a932be13656180 Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Mon, 20 Jul 2026 18:41:03 +0200 Subject: [PATCH 47/78] perf(month): bucket events by day before laying out the scrolling grids (#38) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Opening Continuous stayed slow, and the slowness tracked the number of enabled calendars rather than the window size — which pointed past the provider query to the layout behind it. layoutCalendarWeek opens by filtering the instances it is handed down to the ones touching its seven days. For a single month's grid that is nothing; for the scrolling styles it was a scan of every instance in an eleven-month window, repeated for each of a hundred-odd rows, with a time-zone conversion per check. The work grew with events × rows, so switching every calendar on multiplied it. - DayIndex buckets the window's events by the dates they cover, once, and each row takes the handful on its own seven days. Membership is decided by coversDay itself rather than re-derived from the timestamps — the all-day and timed cases have enough edge cases between them (UTC anchoring, exclusive ends, zero-length events at midnight) that a second implementation would drift. DayIndexTest pins row-for-row equality with the old full scan, in a zone east of UTC. - Only the style on screen is laid out. Adding Dense had quietly doubled the work, since both layouts were built from every load and one was always thrown away. - The paged flow no longer queries under a scrolling style, mirroring the gate the scrolling flow already had. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../calendula/ui/month/MonthViewModel.kt | 99 ++++++++++++-- .../calendula/ui/month/DayIndexTest.kt | 121 ++++++++++++++++++ 2 files changed, 206 insertions(+), 14 deletions(-) create mode 100644 app/src/test/java/de/jeanlucmakiola/calendula/ui/month/DayIndexTest.kt 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 52edd51..dd1ba3f 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 @@ -85,8 +85,12 @@ class MonthViewModel @Inject constructor( val month: StateFlow = _month val state: StateFlow = - combine(_month, weekStart) { ym, ws -> ym to ws } - .flatMapLatest { (ym, ws) -> + combine(_month, weekStart, viewStyle) { ym, ws, style -> Triple(ym, ws, style) } + .flatMapLatest { (ym, ws, style) -> + // The mirror of the gate below: the paged grid is what Paged and + // Split draw, so under a scrolling style this query is a month + // of provider work for a view that isn't on screen. + if (style.isScrolling) return@flatMapLatest flowOf(MonthUiState.Loading) val range = monthGridRange(ym, ws, zone) combine( repository.calendars(), @@ -156,7 +160,7 @@ class MonthViewModel @Inject constructor( repository.calendars(), repository.instances(range), ) { calendars, instances -> - buildContinuousState(window, ws, calendars, instances) + buildContinuousState(window, ws, style, calendars, instances) } } // A load landing is the cue to reach further out. Widening from here @@ -198,23 +202,35 @@ class MonthViewModel @Inject constructor( private fun buildContinuousState( window: IntRange, weekStart: DayOfWeek, + style: MonthViewStyle, calendars: List, instances: List, ): ContinuousMonthUiState { if (calendars.isEmpty()) { return ContinuousMonthUiState.Failure(FailureReason.NoCalendarsConfigured) } - val months = window.associateWith { index -> - val ym = yearMonthForIndex(index) - layoutMonthWeeks(ym, weekStart, instances, zone).map { clipWeekToMonth(it, ym) } - } - // The Dense style's rows, from the same query: every week the window's - // months touch, laid out whole rather than clipped to a month. - val weeks = weekWindowFor(window, weekStart).associateWith { index -> - val days = (0 until 7).map { - weekStartForIndex(index, weekStart).plus(it, DateTimeUnit.DAY) + // Bucketed once for the whole window: a row then takes the handful of + // events on its seven days instead of re-scanning a year of them. + val byDay = DayIndex(instances, zone) + // Only the style on screen is laid out. Building both doubled the work + // for a layout nothing was going to draw. + val months = if (style == MonthViewStyle.Continuous) { + window.associateWith { index -> + val ym = yearMonthForIndex(index) + layoutMonthWeeks(ym, weekStart, byDay, zone).map { clipWeekToMonth(it, ym) } } - layoutCalendarWeek(days, instances, zone) + } else { + emptyMap() + } + val weeks = if (style == MonthViewStyle.Dense) { + weekWindowFor(window, weekStart).associateWith { index -> + val days = (0 until 7).map { + weekStartForIndex(index, weekStart).plus(it, DateTimeUnit.DAY) + } + layoutCalendarWeek(days, byDay.eventsOn(days), zone) + } + } else { + emptyMap() } return ContinuousMonthUiState.Success( today = todayDate, @@ -308,13 +324,68 @@ internal fun layoutMonthWeeks( weekStart: DayOfWeek, instances: List, zone: TimeZone, +): List = layoutMonthWeeks(ym, weekStart, DayIndex(instances, zone), zone) + +/** + * As above, but reusing a [DayIndex] already built for a wider range — what the + * scrolling styles need, since they lay out many months from one query and + * rebuilding the index per month would put the cost straight back. + */ +internal fun layoutMonthWeeks( + ym: YearMonth, + weekStart: DayOfWeek, + byDay: DayIndex, + zone: TimeZone, ): List { val gridStart = firstOfMonth(ym).startOfGridWeek(weekStart) val weekCount = weekRowsInMonth(ym, weekStart) return (0 until weekCount).map { row -> val days = (0 until 7).map { gridStart.plus(row * 7 + it, DateTimeUnit.DAY) } - layoutCalendarWeek(days, instances, zone) + layoutCalendarWeek(days, byDay.eventsOn(days), zone) + } +} + +/** + * Events bucketed by the dates they cover, built once per load. + * + * [layoutCalendarWeek] opens by filtering the instances it was handed down to + * the ones touching its seven days. That is fine for a single month's grid, but + * the scrolling styles lay out a hundred-odd rows from one query, and scanning + * *every* instance in an eleven-month window for each of them made the work grow + * with the number of enabled calendars until opening the view stalled. + * + * Membership is decided by [coversDay] itself rather than by re-deriving the + * rule from the timestamps: the all-day and timed cases have enough edge cases + * between them (UTC anchoring, exclusive ends, zero-length events at midnight) + * that a second implementation would drift. The walk only visits an event's own + * candidate days, so the check runs a couple of times per event rather than once + * per event per row. + */ +internal class DayIndex(instances: List, private val zone: TimeZone) { + + private val byDay: Map> = buildMap> { + instances.forEach { event -> + // All-day events are anchored to UTC midnights; timed ones to the + // device zone. Either way the candidate span is the event's own + // dates, which is one or two days for almost everything. + val anchor = if (event.isAllDay) TimeZone.UTC else zone + var day = event.start.toLocalDateTime(anchor).date + val lastCandidate = event.end.toLocalDateTime(anchor).date + while (day <= lastCandidate) { + if (event.coversDay(day, zone)) getOrPut(day) { mutableListOf() }.add(event) + day = day.plus(1, DateTimeUnit.DAY) + } + } + } + + /** Every event touching any of [days], each listed once, in input order. */ + fun eventsOn(days: List): List { + if (days.isEmpty()) return emptyList() + val hits = days.flatMap { byDay[it].orEmpty() } + // A multi-day event is bucketed on each date it covers, so a row that + // holds several of its days would otherwise list it several times. + return if (hits.size < 2) hits else hits.distinctBy { it.instanceId } } } diff --git a/app/src/test/java/de/jeanlucmakiola/calendula/ui/month/DayIndexTest.kt b/app/src/test/java/de/jeanlucmakiola/calendula/ui/month/DayIndexTest.kt new file mode 100644 index 0000000..d71eb19 --- /dev/null +++ b/app/src/test/java/de/jeanlucmakiola/calendula/ui/month/DayIndexTest.kt @@ -0,0 +1,121 @@ +package de.jeanlucmakiola.calendula.ui.month + +import com.google.common.truth.Truth.assertThat +import de.jeanlucmakiola.calendula.domain.EventInstance +import kotlinx.datetime.DateTimeUnit +import kotlinx.datetime.DayOfWeek +import kotlinx.datetime.LocalDate +import kotlinx.datetime.Month +import kotlinx.datetime.TimeZone +import kotlinx.datetime.YearMonth +import kotlinx.datetime.atTime +import kotlinx.datetime.minus +import kotlinx.datetime.plus +import kotlinx.datetime.toInstant +import org.junit.jupiter.api.Test + +/** + * [DayIndex] exists purely to make the scrolling styles cheap, so what it owes + * is that cheapness changes *nothing*: a row laid out from the index must be the + * row that would have come from scanning every instance. + * + * Run in a zone east of UTC, where the all-day anchoring and the day boundaries + * disagree — the case that has bitten this codebase before (#65). + */ +class DayIndexTest { + + private val zone = TimeZone.of("Europe/Berlin") + private val mon = LocalDate(2026, 6, 8) + private val week = (0..6).map { mon.plus(it, DateTimeUnit.DAY) } + + private var nextId = 0L + + private fun timed(from: LocalDate, startHour: Int, to: LocalDate, endHour: Int): EventInstance { + val id = ++nextId + return EventInstance( + instanceId = id, + eventId = id, + calendarId = 1L, + title = "T$id", + start = from.atTime(startHour, 0).toInstant(zone), + end = to.atTime(endHour, 0).toInstant(zone), + isAllDay = false, + color = 0xFF112233.toInt(), + location = null, + ) + } + + private fun allDay(from: LocalDate, toInclusive: LocalDate): EventInstance { + val id = ++nextId + return EventInstance( + instanceId = id, + eventId = id, + calendarId = 1L, + title = "A$id", + start = from.atTime(0, 0).toInstant(TimeZone.UTC), + end = toInclusive.plus(1, DateTimeUnit.DAY).atTime(0, 0).toInstant(TimeZone.UTC), + isAllDay = true, + color = 0xFF445566.toInt(), + location = null, + ) + } + + /** Every shape the layout distinguishes, plus the boundary cases around them. */ + private fun awkwardEvents(): List = listOf( + timed(mon, 9, mon, 10), + // Crosses midnight into the next day. + timed(mon.plus(1, DateTimeUnit.DAY), 23, mon.plus(2, DateTimeUnit.DAY), 1), + // Ends exactly at midnight: covers its own day, not the next. + timed(mon.plus(3, DateTimeUnit.DAY), 22, mon.plus(4, DateTimeUnit.DAY), 0), + // Zero length, at midnight: covers nothing at all. + timed(mon.plus(4, DateTimeUnit.DAY), 0, mon.plus(4, DateTimeUnit.DAY), 0), + allDay(mon.plus(2, DateTimeUnit.DAY), mon.plus(2, DateTimeUnit.DAY)), + // Runs in from before the row and out past its end. + allDay(mon.minus(3, DateTimeUnit.DAY), mon.plus(9, DateTimeUnit.DAY)), + // Wholly outside the row, both sides. + timed(mon.minus(10, DateTimeUnit.DAY), 9, mon.minus(10, DateTimeUnit.DAY), 10), + allDay(mon.plus(20, DateTimeUnit.DAY), mon.plus(21, DateTimeUnit.DAY)), + // Two on one day, to pin the ordering the index hands back. + timed(mon.plus(5, DateTimeUnit.DAY), 8, mon.plus(5, DateTimeUnit.DAY), 9), + timed(mon.plus(5, DateTimeUnit.DAY), 14, mon.plus(5, DateTimeUnit.DAY), 15), + ) + + @Test + fun `a row from the index is the row from a full scan`() { + val events = awkwardEvents() + val scanned = layoutCalendarWeek(week, events, zone) + val indexed = layoutCalendarWeek(week, DayIndex(events, zone).eventsOn(week), zone) + assertThat(indexed).isEqualTo(scanned) + } + + @Test + fun `a whole month agrees, row for row`() { + val events = awkwardEvents() + val jun = YearMonth(2026, Month.JUNE) + DayOfWeek.entries.forEach { ws -> + assertThat(layoutMonthWeeks(jun, ws, DayIndex(events, zone), zone)) + .isEqualTo(layoutMonthWeeks(jun, ws, events, zone)) + } + } + + @Test + fun `an event covering several of a row's days is listed once`() { + // It is bucketed on each date it covers, so the row would otherwise see + // it repeatedly and lay out a lane per copy. + val long = allDay(mon, mon.plus(4, DateTimeUnit.DAY)) + val events = DayIndex(listOf(long), zone).eventsOn(week) + assertThat(events).containsExactly(long) + } + + @Test + fun `days no event touches come back empty`() { + val index = DayIndex(listOf(timed(mon, 9, mon, 10)), zone) + assertThat(index.eventsOn(listOf(mon.plus(1, DateTimeUnit.DAY)))).isEmpty() + assertThat(index.eventsOn(emptyList())).isEmpty() + } + + @Test + fun `an empty calendar indexes to nothing`() { + assertThat(DayIndex(emptyList(), zone).eventsOn(week)).isEmpty() + } +} From d23a1d5b6da302c6cf3a23fa6097770776401d80 Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Mon, 20 Jul 2026 18:45:32 +0200 Subject: [PATCH 48/78] style(month): drop the split style's divider (#53) The rule between the compact grid and the day pane earns its place in the Continuous header, where it closes a section off; here it only draws a line between two halves the layout already separates. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../java/de/jeanlucmakiola/calendula/ui/month/MonthScreen.kt | 1 - .../de/jeanlucmakiola/calendula/ui/month/MonthStylePreview.kt | 2 -- 2 files changed, 3 deletions(-) diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthScreen.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthScreen.kt index 12ecb20..c4b5505 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthScreen.kt @@ -836,7 +836,6 @@ private fun SplitMonthContent( // full of tappable rows, so it shouldn't also be a month gesture. modifier = rememberMonthSwipeModifier(onSwipeNext, onSwipePrev), ) - HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant) SplitDayPane( date = selected, today = state.today, diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthStylePreview.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthStylePreview.kt index c75a223..e5a47a1 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthStylePreview.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthStylePreview.kt @@ -7,7 +7,6 @@ import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.lazy.rememberLazyListState -import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable import androidx.compose.runtime.remember @@ -138,7 +137,6 @@ internal fun MonthStylePreview( showWeekNumbers = false, onSelectDay = {}, ) - HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant) SplitDayPane( date = today, today = today, From a7ae8ff9129e58abcb266216af29324ef22f5269 Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Mon, 20 Jul 2026 18:46:18 +0200 Subject: [PATCH 49/78] style(month): let the outline alone mark the split style's selected day (#53) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The selected cell also swapped its fill to primaryContainer, which lightened the whole cell — a second state stacked on the day rather than a mark on it, and one that fought today's circle when the two landed together. The outline and the day number carry it. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../java/de/jeanlucmakiola/calendula/ui/month/MonthScreen.kt | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthScreen.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthScreen.kt index c4b5505..d6e4c25 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthScreen.kt @@ -930,8 +930,10 @@ private fun SplitDayCell( onClick: () -> Unit, modifier: Modifier = Modifier, ) { + // Selection is the outline's job alone. Tinting the fill as well lightened + // the whole cell, which read as a second state on top of the day rather than + // as a mark on it — and fought today's own circle when they coincided. val background = when { - isSelected -> MaterialTheme.colorScheme.primaryContainer inMonth -> MaterialTheme.colorScheme.surfaceContainer else -> MaterialTheme.colorScheme.surfaceContainerLow } From 4cee160c125021caa0b2c6d6f9ecbf6b46a0d9e1 Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Mon, 20 Jul 2026 18:52:32 +0200 Subject: [PATCH 50/78] fix(month): steady the split style's paging (#53) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two things made a swipe between months feel rough, both from the grid and the pane disagreeing about what was happening. The grid sized itself to its month, 4 to 6 rows, so every swipe shunted the pane up or down by a row while the grid content swapped underneath with no transition at all. It now always reserves six rows, padding a short month with blank ones — which both holds the pane still and makes the paged style's directional slide safe to use here, since there is no longer a height change to animate under it. The pane also flashed "Nothing scheduled" mid-page. Paging moves the selection to the new month before that month's events have arrived, and the pane was handed `instancesByDay[selected].orEmpty()` — so a day that simply wasn't loaded yet was indistinguishable from a day with nothing on it, and got reported as free. It now takes a nullable list and shows skeleton rows for the gap, saying "still looking" rather than making a claim about the day. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../calendula/ui/month/MonthScreen.kt | 97 ++++++++++++++++--- 1 file changed, 82 insertions(+), 15 deletions(-) diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthScreen.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthScreen.kt index d6e4c25..4ef64d3 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthScreen.kt @@ -354,6 +354,7 @@ fun MonthScreen( SplitMonthContent( state = state, selected = selectedDate, + slideDir = slideDir, showWeekNumbers = showWeekNumbers, onSwipeNext = goNext, onSwipePrev = goPrev, @@ -806,14 +807,16 @@ private fun rememberMonthSwipeModifier( * Split style content: the compact grid keeps the month swipe, the pane below it * lists whatever day is selected. * - * No [AnimatedContent] here, unlike the paged style. The grid is 4–6 rows tall - * depending on the month, so sliding one month over another would animate a - * height change under a pane that is trying to hold still. + * The grid slides between months like the paged style, which it can only do + * because it always reserves [SPLIT_GRID_ROWS] rows. Sized to its own month it + * stood 4–6 rows tall, so every swipe shunted the pane up or down by a row on + * top of swapping the grid — the pane now holds still and only the grid moves. */ @Composable private fun SplitMonthContent( state: MonthUiState, selected: LocalDate, + slideDir: Int, showWeekNumbers: Boolean, onSwipeNext: () -> Unit, onSwipePrev: () -> Unit, @@ -823,23 +826,43 @@ private fun SplitMonthContent( onEventClick: (EventInstance) -> Unit, onCreateEvent: (LocalDate) -> Unit, ) { + val slideSpec = rememberCalendarSlideSpec() + val fadeSpec = rememberCalendarFadeSpec() + val reduceMotion = rememberReduceMotion() + // The swipe wraps the grid rather than living inside it: mid-transition + // there are two grids, and the gesture belongs to neither. The pane is left + // out of it — it scrolls and is full of tappable rows. + val swipeModifier = rememberMonthSwipeModifier(onSwipeNext, onSwipePrev) + when (state) { MonthUiState.Loading -> MonthGridLoading() is MonthUiState.Failure -> CalendarFailure(reason = state.reason, onRetry = onRetry) is MonthUiState.Success -> Column(Modifier.fillMaxSize()) { - SplitMonthGrid( - state = state, - selected = selected, - showWeekNumbers = showWeekNumbers, - onSelectDay = onSelectDay, - // The swipe lives on the grid alone — the pane scrolls and is - // full of tappable rows, so it shouldn't also be a month gesture. - modifier = rememberMonthSwipeModifier(onSwipeNext, onSwipePrev), - ) + AnimatedContent( + targetState = state, + modifier = swipeModifier, + // Keyed on the month alone, so a provider notification refreshing + // the month you are on updates in place instead of sliding. + contentKey = { it.month }, + transitionSpec = { + calendarSlideTransition(slideDir, slideSpec, fadeSpec, reduceMotion) + }, + label = "split-month-transition", + ) { s -> + SplitMonthGrid( + state = s, + selected = selected, + showWeekNumbers = showWeekNumbers, + onSelectDay = onSelectDay, + ) + } SplitDayPane( date = selected, today = state.today, - events = state.instancesByDay[selected].orEmpty(), + // Null, not empty: the selection moves to the new month before + // its data arrives, and a missing key means "not loaded yet". + // Passing an empty list would claim the day was free. + events = state.instancesByDay[selected], zone = state.zone, onOpenDay = onOpenDay, onEventClick = onEventClick, @@ -861,6 +884,13 @@ private val SPLIT_ROW_HEIGHT = 46.dp private val SPLIT_DOT_SIZE = 5.dp private const val SPLIT_MAX_DOTS = 3 +/** + * Rows the split grid always reserves — the most any month needs. A month that + * fits in fewer pads the remainder with blank rows rather than shrinking, which + * is what lets the pane below hold still from month to month. + */ +private const val SPLIT_GRID_ROWS = 6 + /** * The split style's grid (#53): the month compressed to day numbers and event * dots, with the selected day listed underneath by [SplitDayPane]. @@ -909,6 +939,12 @@ internal fun SplitMonthGrid( } } } + // Hold the grid at a constant height whatever shape the month is, so the + // pane beneath it doesn't move as you page and one month can slide over + // another without a height change under it. + repeat(SPLIT_GRID_ROWS - state.weeks.size) { + Spacer(Modifier.fillMaxWidth().height(SPLIT_ROW_HEIGHT)) + } } } @@ -1005,7 +1041,8 @@ private fun SplitDots(events: List, dark: Boolean) { internal fun SplitDayPane( date: LocalDate, today: LocalDate, - events: List, + /** Null while the selected day's month is still loading; empty means free. */ + events: List?, zone: TimeZone, onOpenDay: (LocalDate) -> Unit, onEventClick: (EventInstance) -> Unit, @@ -1015,7 +1052,12 @@ internal fun SplitDayPane( val dimCutoff = LocalDimCutoff.current Column(modifier = modifier) { AgendaDayHeader(date = date, today = today, onOpenDay = onOpenDay) - if (events.isEmpty()) { + if (events == null) { + // The day isn't in the loaded grid yet — paging lands the selection + // on the new month before its events arrive. "Nothing scheduled" + // here would be a claim about the day rather than about the wait. + SplitPaneSkeleton() + } else if (events.isEmpty()) { AgendaEmptyDayRow( text = stringResource(R.string.month_split_no_events), onClick = { onCreateEvent(date) }, @@ -1043,6 +1085,31 @@ internal fun SplitDayPane( } } +/** Stand-in rows for a day pane whose month hasn't arrived yet. */ +@Composable +private fun SplitPaneSkeleton() { + val pulse = rememberSkeletonPulse() + Column( + modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 4.dp), + verticalArrangement = Arrangement.spacedBy(6.dp), + ) { + repeat(2) { + Box( + modifier = Modifier + .fillMaxWidth() + .height(SPLIT_SKELETON_ROW_HEIGHT) + .alpha(pulse) + .background( + MaterialTheme.colorScheme.surfaceContainer, + MaterialTheme.shapes.medium, + ), + ) + } + } +} + +private val SPLIT_SKELETON_ROW_HEIGHT = 56.dp + /** A week the sliding window hasn't loaded yet — same height, so nothing jumps. */ @Composable private fun ContinuousWeekPlaceholder() { From e691336b5eaf950694f50fbd830f11fd18e5528b Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Mon, 20 Jul 2026 18:57:45 +0200 Subject: [PATCH 51/78] style(calendar): make the page transition a proper shared-axis (#38, #53) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Paging between months, weeks and days looked rough, and the transition was the reason: a full-width slide with no fade at all. AnimatedContent stacks the two pages and both were fully opaque, so what you saw was one grid racing across another and clipping at the viewport edge — the movement was carrying the entire transition, over the longest distance available. It is now M3's shared-axis X. The offset only hints the direction — a fifth of the width — while a cross-fade does the swapping, position springs and opacity eases, and the size transform no longer clips. All three calendar views share this transition, so all three settle. Also lifts the month view's swipe threshold from 6dp to the 24dp the week and day views already used. Six is inside the distance a tap wanders, so brushing the grid turned the page — which reads as the animation firing at random rather than as an over-eager gesture. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../ui/common/CalendarTransitions.kt | 58 ++++++++++++++----- .../calendula/ui/month/MonthScreen.kt | 12 +++- 2 files changed, 55 insertions(+), 15 deletions(-) diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/CalendarTransitions.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/CalendarTransitions.kt index 54a5b7a..cf57422 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/CalendarTransitions.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/CalendarTransitions.kt @@ -1,6 +1,7 @@ package de.jeanlucmakiola.calendula.ui.common import androidx.compose.animation.ContentTransform +import androidx.compose.animation.SizeTransform import androidx.compose.animation.core.FiniteAnimationSpec import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut @@ -23,9 +24,13 @@ import androidx.compose.ui.unit.IntOffset */ /** - * The M3 Expressive spatial spring used for the month/week slide: the *fast* - * spring-physics spec from the active motion scheme — snappy with a subtle - * springy settle, rather than a fixed easing curve. + * The M3 Expressive spatial spring used for the month/week/day slide: the + * *default* spring-physics spec from the active motion scheme, rather than a + * fixed easing curve. + * + * Default rather than fast: `fastSpatialSpec` is tuned for small, incidental + * movement, and driving a whole calendar page with it made the settle read as a + * twitch instead of a glide. * * Read it in a composable scope (this helper) so it can be captured by the * non-composable `AnimatedContent` transitionSpec lambda. @@ -33,27 +38,34 @@ import androidx.compose.ui.unit.IntOffset @OptIn(ExperimentalMaterial3ExpressiveApi::class) @Composable fun rememberCalendarSlideSpec(): FiniteAnimationSpec = - MaterialTheme.motionScheme.fastSpatialSpec() + MaterialTheme.motionScheme.defaultSpatialSpec() /** - * The fast effects spec from the active motion scheme, for opacity (fade) - * transitions. Captured in composable scope alongside [rememberCalendarSlideSpec] - * for use in non-composable transition lambdas and as the reduced-motion fallback. + * The effects spec from the active motion scheme, for the opacity half of the + * transition. Captured in composable scope alongside [rememberCalendarSlideSpec] + * for use in non-composable transition lambdas, and reused on its own as the + * reduced-motion fallback. + * + * Opacity gets its own spec on purpose: M3 Expressive springs *position* and + * eases *opacity*, and a fade that bounced with the slide would shimmer. */ @OptIn(ExperimentalMaterial3ExpressiveApi::class) @Composable fun rememberCalendarFadeSpec(): FiniteAnimationSpec = - MaterialTheme.motionScheme.fastEffectsSpec() + MaterialTheme.motionScheme.defaultEffectsSpec() /** - * Horizontal slide for navigating between adjacent months/weeks/days. + * Navigating between adjacent months/weeks/days, as M3's shared-axis X: the + * outgoing page slides and fades one way while the incoming one arrives from the + * other, position on a spring and opacity on an easing curve. * * @param slideDir +1 = forward (incoming from the right), -1 = back, 0 = jump * (e.g. "today"); a jump reuses the forward direction. * @param spec spatial animation spec, typically [rememberCalendarSlideSpec]. - * @param fadeSpec effects spec for the reduced-motion fade, typically + * @param fadeSpec effects spec for the opacity half, and for the whole + * transition under reduced motion; typically * [rememberCalendarFadeSpec]. - * @param reduceMotion when true, swap the directional slide for a plain cross-fade. + * @param reduceMotion when true, drop the movement and cross-fade alone. */ fun calendarSlideTransition( slideDir: Int, @@ -65,6 +77,26 @@ fun calendarSlideTransition( return fadeIn(fadeSpec).togetherWith(fadeOut(fadeSpec)) } val dir = if (slideDir == 0) 1 else slideDir - return slideInHorizontally(spec) { w -> dir * w } - .togetherWith(slideOutHorizontally(spec) { w -> -dir * w }) + return ContentTransform( + targetContentEnter = + slideInHorizontally(spec) { w -> dir * w / SLIDE_TRAVEL_DIVISOR } + fadeIn(fadeSpec), + initialContentExit = + slideOutHorizontally(spec) { w -> -dir * w / SLIDE_TRAVEL_DIVISOR } + fadeOut(fadeSpec), + // AnimatedContent clips to the animating container by default, which + // shears the pages against the viewport edge as they pass. There is no + // size change here to contain — both pages are the same grid. + sizeTransform = SizeTransform(clip = false), + ) } + +/** + * How far a page travels, as a fraction of the container width. + * + * A full width was the obvious reading of "paging", but the two pages are + * stacked and both opaque, so a full-width slide showed one grid racing across + * another — the movement carried the whole transition and had a long way to go. + * Under M3's shared-axis pattern the offset only has to *hint* the direction + * while the cross-fade does the swapping, so a fifth of the width is plenty and + * leaves nothing skating past. + */ +private const val SLIDE_TRAVEL_DIVISOR = 5 diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthScreen.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthScreen.kt index 4ef64d3..7feb567 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthScreen.kt @@ -777,15 +777,20 @@ internal fun DenseMonthGrid( /** * The month-changing horizontal swipe, shared by the paged and split styles. - * Accumulates the drag and commits past a small threshold on release — the grid + * Accumulates the drag and commits past a threshold on release — the grid * doesn't follow the finger, so there is no distance to rubber-band against. + * + * The threshold matches the week and day views'. It used to be 6dp, which is + * inside the distance a tap wanders: brushing the grid changed the month, and a + * page that turns on an unintended gesture reads as the animation misfiring + * rather than as the gesture being over-eager. */ @Composable private fun rememberMonthSwipeModifier( onSwipeNext: () -> Unit, onSwipePrev: () -> Unit, ): Modifier { - val threshold = with(LocalDensity.current) { 6.dp.toPx() } + val threshold = with(LocalDensity.current) { MONTH_SWIPE_THRESHOLD.toPx() } var dragAccum by remember { mutableFloatStateOf(0f) } return Modifier.pointerInput(Unit) { detectHorizontalDragGestures( @@ -803,6 +808,9 @@ private fun rememberMonthSwipeModifier( } } +/** Drag distance that commits a month change, matching the week and day views. */ +private val MONTH_SWIPE_THRESHOLD = 24.dp + /** * Split style content: the compact grid keeps the month swipe, the pane below it * lists whatever day is selected. From f9619e74d936dec5ed8e7f9830063ecfcd10ac77 Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Mon, 20 Jul 2026 19:04:08 +0200 Subject: [PATCH 52/78] style(month): settle the split style's selection and day pane (#53) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three things still moved badly while paging. The selection marker was thrown across the outgoing grid. `selected` was read from outside the AnimatedContent, so both pages rendered the *incoming* selection — and the new month's 1st is a day the old grid still shows among its trailing days, so the outline jumped there (bottom right) before the new page slid in and put it where it belonged (top left). The selection now travels with the state, so each page keeps its own and the marker only ever appears where its month has it. Moving the selection within a month also jumped, outline blinking out of one cell and into another. Each cell now fades its own outline, so it reads as one mark crossing the grid. Snapped under reduced motion. And the day pane swapped its rows outright, popping one list out and another in. It now cross-fades, the incoming list rising as it arrives — the same entrance the rest of the family uses for resolved content — with the skeleton handing over the same way. Rows within a day also animate their placement, so an event arriving from a sync nudges its neighbours instead of teleporting them. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../calendula/ui/month/MonthScreen.kt | 115 ++++++++++++------ 1 file changed, 77 insertions(+), 38 deletions(-) diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthScreen.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthScreen.kt index 7feb567..4c9badf 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthScreen.kt @@ -2,10 +2,14 @@ package de.jeanlucmakiola.calendula.ui.month import androidx.compose.animation.AnimatedContent import androidx.compose.animation.core.RepeatMode +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.snap import androidx.compose.animation.core.animateFloat import androidx.compose.animation.core.infiniteRepeatable import androidx.compose.animation.core.rememberInfiniteTransition import androidx.compose.animation.core.tween +import androidx.compose.animation.fadeOut +import androidx.compose.animation.togetherWith import androidx.compose.foundation.background import androidx.compose.foundation.border import androidx.compose.foundation.clickable @@ -102,6 +106,8 @@ import de.jeanlucmakiola.calendula.ui.common.ViewSwitcherPill import de.jeanlucmakiola.calendula.ui.common.calendarSlideTransition import de.jeanlucmakiola.calendula.ui.common.rememberCalendarFadeSpec import de.jeanlucmakiola.floret.components.positionOf +import de.jeanlucmakiola.floret.identity.animateItemMotion +import de.jeanlucmakiola.floret.identity.itemEnter import de.jeanlucmakiola.floret.identity.rememberReduceMotion import de.jeanlucmakiola.floret.locale.currentLocale import de.jeanlucmakiola.calendula.ui.common.rememberCalendarSlideSpec @@ -847,19 +853,25 @@ private fun SplitMonthContent( is MonthUiState.Failure -> CalendarFailure(reason = state.reason, onRetry = onRetry) is MonthUiState.Success -> Column(Modifier.fillMaxSize()) { AnimatedContent( - targetState = state, + // The selection travels *with* the state so each page keeps its + // own. Read from outside, both pages would show the incoming + // one, and paging visibly threw the marker across the outgoing + // grid — onto the new month's 1st, which the old grid still + // shows among its trailing days — before the new page arrived. + targetState = state to selected, modifier = swipeModifier, // Keyed on the month alone, so a provider notification refreshing - // the month you are on updates in place instead of sliding. - contentKey = { it.month }, + // the month you are on — or a tap moving the selection within it + // — updates in place instead of sliding. + contentKey = { (s, _) -> s.month }, transitionSpec = { calendarSlideTransition(slideDir, slideSpec, fadeSpec, reduceMotion) }, label = "split-month-transition", - ) { s -> + ) { (s, sel) -> SplitMonthGrid( state = s, - selected = selected, + selected = sel, showWeekNumbers = showWeekNumbers, onSelectDay = onSelectDay, ) @@ -981,17 +993,25 @@ private fun SplitDayCell( inMonth -> MaterialTheme.colorScheme.surfaceContainer else -> MaterialTheme.colorScheme.surfaceContainerLow } + // Each cell fades its own outline, so moving the selection reads as one + // mark crossing the grid rather than as an outline blinking out here and + // reappearing there. Snapped under reduced motion. + val reduceMotion = rememberReduceMotion() + val fadeSpec = rememberCalendarFadeSpec() + val selection by animateFloatAsState( + targetValue = if (isSelected) 1f else 0f, + animationSpec = if (reduceMotion) snap() else fadeSpec, + label = "split-day-selection", + ) Box( modifier = modifier .padding(horizontal = CELL_GAP, vertical = 1.dp) .clip(CELL_SHAPE) .background(background) - .then( - if (isSelected) { - Modifier.border(1.5.dp, MaterialTheme.colorScheme.primary, CELL_SHAPE) - } else { - Modifier - }, + .border( + width = 1.5.dp, + color = MaterialTheme.colorScheme.primary.copy(alpha = selection), + shape = CELL_SHAPE, ) .selectable(selected = isSelected, onClick = onClick), contentAlignment = Alignment.TopCenter, @@ -1058,35 +1078,54 @@ internal fun SplitDayPane( modifier: Modifier = Modifier, ) { val dimCutoff = LocalDimCutoff.current + // A day's list used to be replaced outright, so changing day popped one set + // of rows out and another in. The incoming list rises as it fades, which is + // the same entrance the rest of the family uses for resolved content. + val enter = itemEnter() + val fadeSpec = rememberCalendarFadeSpec() Column(modifier = modifier) { AgendaDayHeader(date = date, today = today, onOpenDay = onOpenDay) - if (events == null) { - // The day isn't in the loaded grid yet — paging lands the selection - // on the new month before its events arrive. "Nothing scheduled" - // here would be a claim about the day rather than about the wait. - SplitPaneSkeleton() - } else if (events.isEmpty()) { - AgendaEmptyDayRow( - text = stringResource(R.string.month_split_no_events), - onClick = { onCreateEvent(date) }, - ) - } else { - LazyColumn( - // Bottom inset clears the FAB stack so the last row stays tappable. - contentPadding = PaddingValues(bottom = 96.dp), - ) { - itemsIndexed( - items = events, - key = { _, event -> event.instanceId }, - ) { index, event -> - AgendaEventRow( - event = event, - day = date, - zone = zone, - position = positionOf(index, events.size), - dimmed = dimCutoff != null && event.hasEnded(dimCutoff), - onClick = { onEventClick(event) }, - ) + AnimatedContent( + targetState = date to events, + modifier = Modifier.weight(1f).fillMaxWidth(), + // Whether the day is still loading is part of the identity, so the + // skeleton hands over to the real list with the same transition. + contentKey = { (d, e) -> d to (e == null) }, + transitionSpec = { enter togetherWith fadeOut(fadeSpec) }, + label = "split-day-pane", + ) { (day, dayEvents) -> + when { + // The day isn't in the loaded grid yet — paging lands the + // selection on the new month before its events arrive. "Nothing + // scheduled" here would be a claim about the day rather than + // about the wait. + dayEvents == null -> SplitPaneSkeleton() + dayEvents.isEmpty() -> AgendaEmptyDayRow( + text = stringResource(R.string.month_split_no_events), + onClick = { onCreateEvent(day) }, + ) + else -> LazyColumn( + // Bottom inset clears the FAB stack so the last row stays + // tappable. + contentPadding = PaddingValues(bottom = 96.dp), + ) { + itemsIndexed( + items = dayEvents, + key = { _, event -> event.instanceId }, + ) { index, event -> + AgendaEventRow( + event = event, + day = day, + zone = zone, + position = positionOf(index, dayEvents.size), + dimmed = dimCutoff != null && event.hasEnded(dimCutoff), + onClick = { onEventClick(event) }, + // An event added to or removed from the day you are + // already looking at moves its neighbours rather + // than teleporting them. + modifier = animateItemMotion(), + ) + } } } } From 39cc70be95068e5301c76866df7a9561f0971fec Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Mon, 20 Jul 2026 19:10:08 +0200 Subject: [PATCH 53/78] fix(month): keep the split selection inside the month that owns it (#53) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The marker could still be caught fading in on the outgoing page. Paging changes the selection immediately but the state only once the new month has loaded, so until then the content key — the state's month — is unchanged and AnimatedContent updates the page *in place*: the old grid really was told the next month's 1st was selected, and it shows that date among its trailing days. A page now marks only the days its own month owns. Selection always lands inside the month it belongs to, so nothing legitimate is lost, and the phantom goes without timing the fade against the page transition — a delay would have moved it rather than removed it, and slowed selecting a day within a page besides. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../calendula/ui/month/MonthScreen.kt | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthScreen.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthScreen.kt index 4c9badf..ffde964 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthScreen.kt @@ -946,12 +946,22 @@ internal fun SplitMonthGrid( ) } week.days.forEach { day -> + val inMonth = day.month == month.month && day.year == month.year SplitDayCell( date = day, events = state.instancesByDay[day].orEmpty(), isToday = day == state.today, - isSelected = day == selected, - inMonth = day.month == month.month && day.year == month.year, + // A page marks only the days its own month owns. Paging + // moves the selection before this month's replacement + // has loaded, so for a moment the grid is asked to mark + // the *next* month's 1st — a date it shows among its + // trailing days, where the marker would fade in just + // before the page slid away. Selection always lands + // inside the month it belongs to, so scoping it here + // removes that without timing anything against the + // page transition. + isSelected = day == selected && inMonth, + inMonth = inMonth, dark = dark, onClick = { onSelectDay(day) }, modifier = Modifier.weight(1f).fillMaxHeight(), From 1499b5803d7ce62cc478b4b5a3fdde5434da6e5c Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Mon, 20 Jul 2026 19:26:57 +0200 Subject: [PATCH 54/78] fix(month): seat the split style's dots by lane (#53) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The dots gathered a day's *distinct colours*, which meant dot i stood for no particular event and two events sharing a calendar collapsed into one — undercounting the day. Seat them off the same lane assignment the paged grid draws from, so a dot and the bar in that lane are the same event. That fixes the count, and it is what will let one morph into the other when the grid expands. The cap is now MAX_EVENT_ROWS rather than a constant of its own: the two have to agree or a dot would have no bar to become. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../calendula/ui/month/MonthScreen.kt | 39 ++++-- .../calendula/ui/month/MonthUiState.kt | 28 ++++ .../calendula/ui/month/LaneEventsTest.kt | 129 ++++++++++++++++++ 3 files changed, 183 insertions(+), 13 deletions(-) create mode 100644 app/src/test/java/de/jeanlucmakiola/calendula/ui/month/LaneEventsTest.kt diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthScreen.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthScreen.kt index ffde964..b9f70dd 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthScreen.kt @@ -902,7 +902,9 @@ private fun SplitMonthContent( */ private val SPLIT_ROW_HEIGHT = 46.dp private val SPLIT_DOT_SIZE = 5.dp -private const val SPLIT_MAX_DOTS = 3 +// Dots are capped by MAX_EVENT_ROWS, not a constant of their own: they stand for +// the paged grid's lanes, so the two caps have to be the same number or a dot +// would have no bar to become (#53). /** * Rows the split grid always reserves — the most any month needs. A month that @@ -945,11 +947,15 @@ internal fun SplitMonthGrid( .fillMaxHeight(), ) } - week.days.forEach { day -> + week.days.forEachIndexed { col, day -> val inMonth = day.month == month.month && day.year == month.year + // Seated by lane rather than gathered by colour, so each dot + // is the event the expanded grid draws in that same lane. + val seated = week.laneEvents(col, day, MAX_EVENT_ROWS) SplitDayCell( date = day, - events = state.instancesByDay[day].orEmpty(), + events = seated, + hidden = (week.countByDay[day] ?: 0) - seated.size, isToday = day == state.today, // A page marks only the days its own month owns. Paging // moves the selection before this month's replacement @@ -979,7 +985,7 @@ internal fun SplitMonthGrid( } /** - * One compact day: its number over up to [SPLIT_MAX_DOTS] event dots. + * One compact day: its number over up to [MAX_EVENT_ROWS] lane-seated event dots. * * Selection and today are deliberately different signals — a tinted, outlined * cell versus the filled circle the other views already use for today — so the @@ -989,6 +995,8 @@ internal fun SplitMonthGrid( private fun SplitDayCell( date: LocalDate, events: List, + /** Events on this day that didn't fit a lane, shown as a "+N" beside the dots. */ + hidden: Int, isToday: Boolean, isSelected: Boolean, inMonth: Boolean, @@ -1037,32 +1045,37 @@ private fun SplitDayCell( modifier = Modifier.fillMaxWidth(), ) Spacer(Modifier.height(2.dp)) - SplitDots(events = events, dark = dark) + SplitDots(events = events, hidden = hidden, dark = dark) } } } -/** Up to three colour dots for a day, plus a count when more are hidden. */ +/** + * One dot per seated event, in lane order, plus a "+N" for those that didn't fit. + * + * Deliberately *not* de-duplicated by colour: [events] arrives lane-seated so the + * dots line up with the bars the expanded grid draws, and collapsing two events + * that share a calendar into one dot would both undercount the day and leave a + * bar with no dot to grow out of. + */ @Composable -private fun SplitDots(events: List, dark: Boolean) { +private fun SplitDots(events: List, hidden: Int, dark: Boolean) { if (events.isEmpty()) return val soften = LocalSoftenColors.current - val colors = remember(events) { events.map { it.color }.distinct().take(SPLIT_MAX_DOTS) } - val extra = events.size - colors.size Row( horizontalArrangement = Arrangement.spacedBy(2.dp), verticalAlignment = Alignment.CenterVertically, ) { - colors.forEach { argb -> + events.forEach { event -> Box( modifier = Modifier .size(SPLIT_DOT_SIZE) - .background(eventFill(argb, dark, soften), CircleShape), + .background(eventFill(event.color, dark, soften), CircleShape), ) } - if (extra > 0) { + if (hidden > 0) { Text( - text = "+$extra", + text = "+$hidden", style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurfaceVariant, ) diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthUiState.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthUiState.kt index 564466b..3b1bc80 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthUiState.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthUiState.kt @@ -39,6 +39,34 @@ data class MonthWeek( val countByDay: Map, ) +/** + * The events occupying each lane of [day] — column [col] of this week — in lane + * order, capped at [laneCap] lanes. + * + * This is the same seating [spans]/[timedByDay] get when the paged grid draws a + * week: bars keep the lane the row layout gave them, and the day's timed events + * fill whatever slots are left, top-most first. Reading it here means the split + * style's dots and the paged style's bars describe a day in the *same order*, so + * dot _i_ and lane _i_ are the same event and one can morph into the other (#53). + * + * Deriving the dots independently — by distinct colour, as they first were — + * left dot _i_ standing for no particular event, and quietly merged two events + * that shared a calendar into a single dot. + */ +fun MonthWeek.laneEvents(col: Int, day: LocalDate, laneCap: Int): List { + val byLane = arrayOfNulls(laneCap) + spans.forEach { span -> + if (span.lane < laneCap && col in span.startCol..span.endCol) { + byLane[span.lane] = span.event + } + } + val free = (0 until laneCap).filter { byLane[it] == null } + timedByDay[day].orEmpty().take(free.size).forEachIndexed { i, event -> + byLane[free[i]] = event + } + return byLane.filterNotNull() +} + /** * State for the continuous style (#38): a vertical stream of *self-contained* * months rather than one undifferentiated run of weeks. Each month is keyed by diff --git a/app/src/test/java/de/jeanlucmakiola/calendula/ui/month/LaneEventsTest.kt b/app/src/test/java/de/jeanlucmakiola/calendula/ui/month/LaneEventsTest.kt new file mode 100644 index 0000000..0374ab3 --- /dev/null +++ b/app/src/test/java/de/jeanlucmakiola/calendula/ui/month/LaneEventsTest.kt @@ -0,0 +1,129 @@ +package de.jeanlucmakiola.calendula.ui.month + +import com.google.common.truth.Truth.assertThat +import de.jeanlucmakiola.calendula.domain.EventInstance +import kotlinx.datetime.DateTimeUnit +import kotlinx.datetime.DayOfWeek +import kotlinx.datetime.LocalDate +import kotlinx.datetime.Month +import kotlinx.datetime.TimeZone +import kotlinx.datetime.YearMonth +import kotlinx.datetime.atTime +import kotlinx.datetime.plus +import kotlinx.datetime.toInstant +import org.junit.jupiter.api.Test + +/** + * What lets the split style's dots morph into the paged style's bars (#53): both + * read a day off the *same* lane seating, so dot _i_ and lane _i_ are one event. + */ +class LaneEventsTest { + + private val zone = TimeZone.UTC + private val jul26 = YearMonth(2026, Month.JULY) + + /** July 2026 starts on a Wednesday, so this row — Jul 6–12 — sits wholly inside it. */ + private fun rowOfJuly6(events: List) = + layoutMonthWeeks(jul26, DayOfWeek.MONDAY, events, zone)[1] + + private fun allDay(from: LocalDate, toInclusive: LocalDate, id: Long, color: Int = BLUE) = + EventInstance( + instanceId = id, + eventId = id, + calendarId = 1L, + title = "A$id", + start = from.atTime(0, 0).toInstant(zone), + end = toInclusive.plus(1, DateTimeUnit.DAY).atTime(0, 0).toInstant(zone), + isAllDay = true, + color = color, + location = null, + ) + + private fun timed(date: LocalDate, hour: Int, id: Long, color: Int = RED) = EventInstance( + instanceId = id, + eventId = id, + calendarId = 1L, + title = "T$id", + start = date.atTime(hour, 0).toInstant(zone), + end = date.atTime(hour + 1, 0).toInstant(zone), + isAllDay = false, + color = color, + location = null, + ) + + @Test + fun `a bar keeps its lane and the day's timed events fill what's left`() { + val bar = allDay(LocalDate(2026, 7, 7), LocalDate(2026, 7, 9), id = 1L) + val meeting = timed(LocalDate(2026, 7, 7), hour = 9, id = 2L) + val week = rowOfJuly6(listOf(bar, meeting)) + + // Jul 7 is column 1 of a Monday-anchored row starting Jul 6. + assertThat(week.laneEvents(col = 1, day = LocalDate(2026, 7, 7), laneCap = 3)) + .containsExactly(bar, meeting) + .inOrder() + } + + @Test + fun `a day the bar misses seats its own events from lane zero`() { + val bar = allDay(LocalDate(2026, 7, 7), LocalDate(2026, 7, 9), id = 1L) + val monday = timed(LocalDate(2026, 7, 6), hour = 9, id = 2L) + val week = rowOfJuly6(listOf(bar, monday)) + + assertThat(week.laneEvents(col = 0, day = LocalDate(2026, 7, 6), laneCap = 3)) + .containsExactly(monday) + } + + @Test + fun `a multi-day bar is seated on every day it covers`() { + val bar = allDay(LocalDate(2026, 7, 7), LocalDate(2026, 7, 9), id = 1L) + val week = rowOfJuly6(listOf(bar)) + + (1..3).forEach { col -> + val day = LocalDate(2026, 7, 6 + col) + assertThat(week.laneEvents(col, day, laneCap = 3)).containsExactly(bar) + } + assertThat(week.laneEvents(col = 4, day = LocalDate(2026, 7, 10), laneCap = 3)).isEmpty() + } + + /** The bug the colour-gathered dots had: one dot for two events on one calendar. */ + @Test + fun `events sharing a colour each keep their own lane`() { + val first = timed(LocalDate(2026, 7, 6), hour = 9, id = 1L, color = RED) + val second = timed(LocalDate(2026, 7, 6), hour = 14, id = 2L, color = RED) + val week = rowOfJuly6(listOf(first, second)) + + assertThat(week.laneEvents(col = 0, day = LocalDate(2026, 7, 6), laneCap = 3)) + .containsExactly(first, second) + .inOrder() + } + + @Test + fun `seating stops at the cap and leaves the rest to the overflow count`() { + val events = (0 until 5).map { timed(LocalDate(2026, 7, 6), hour = 8 + it, id = it + 1L) } + val week = rowOfJuly6(events) + + val seated = week.laneEvents(col = 0, day = LocalDate(2026, 7, 6), laneCap = 3) + assertThat(seated).hasSize(3) + assertThat(seated).containsExactlyElementsIn(events.take(3)).inOrder() + assertThat(week.countByDay[LocalDate(2026, 7, 6)]!! - seated.size).isEqualTo(2) + } + + /** A bar parked below the cap is out of view, so it takes no dot with it. */ + @Test + fun `a bar beyond the cap is left out`() { + val bars = (0 until 4).map { + allDay(LocalDate(2026, 7, 6), LocalDate(2026, 7, 8), id = it + 1L) + } + val week = rowOfJuly6(bars) + + val seated = week.laneEvents(col = 0, day = LocalDate(2026, 7, 6), laneCap = 3) + assertThat(seated).hasSize(3) + assertThat(week.spans.filter { it.lane >= 3 }.map { it.event }) + .containsNoneIn(seated) + } + + private companion object { + const val BLUE = 0xFF3366CC.toInt() + const val RED = 0xFFCC3333.toInt() + } +} From f15fffa7990f2f4a81d73a0145f8f66feef844cf Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Mon, 20 Jul 2026 19:30:37 +0200 Subject: [PATCH 55/78] feat(month): let the split style pull open to the full month (#53) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Drag the compact grid down and the day pane gives way to the month drawn the paged style's way — real bars and pills, not dots; drag up to bring the pane back. Split could show you which days were busy, or one day in full, but never the month's events at once without a trip to Settings. The month swipe grows a second axis to do it, so the two are locked to one decision per gesture: independent horizontal and vertical detectors would each see their own component of a diagonal drag and both fire. Paging keeps ties, so an ambiguous drag still reads as it did. A tap in the expanded grid picks the day and drops back, which makes it a chooser you dip into rather than a mode you can get stranded in — so the grid needs the selection outline too, which MonthGrid now takes optionally. Expansion is state, not a preference: persisted, someone would expand it once and later find their Split style changed with nothing to explain why. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../calendula/ui/month/MonthScreen.kt | 348 +++++++++++++++--- app/src/main/res/values/strings.xml | 2 + 2 files changed, 291 insertions(+), 59 deletions(-) diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthScreen.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthScreen.kt index b9f70dd..4ee8200 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthScreen.kt @@ -8,12 +8,13 @@ import androidx.compose.animation.core.animateFloat import androidx.compose.animation.core.infiniteRepeatable import androidx.compose.animation.core.rememberInfiniteTransition import androidx.compose.animation.core.tween +import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut import androidx.compose.animation.togetherWith import androidx.compose.foundation.background import androidx.compose.foundation.border import androidx.compose.foundation.clickable -import androidx.compose.foundation.gestures.detectHorizontalDragGestures +import androidx.compose.foundation.gestures.detectDragGestures import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box @@ -60,16 +61,18 @@ import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue import androidx.compose.runtime.key import androidx.compose.runtime.snapshotFlow -import androidx.compose.runtime.mutableFloatStateOf import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.alpha import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clipToBounds +import androidx.compose.ui.geometry.Offset import androidx.compose.ui.graphics.Color import androidx.compose.ui.input.nestedscroll.nestedScroll import androidx.compose.ui.input.pointer.pointerInput @@ -124,6 +127,7 @@ import kotlinx.datetime.plus import kotlinx.datetime.YearMonth import kotlinx.datetime.toJavaLocalDate import kotlinx.datetime.toLocalDateTime +import kotlin.math.abs import kotlin.time.Clock import java.time.format.TextStyle as JavaTextStyle import java.util.Locale @@ -573,6 +577,12 @@ internal fun MonthGrid( state: MonthUiState.Success, showWeekNumbers: Boolean, onOpenDay: (LocalDate) -> Unit, + /** + * Outlined when set, matching the compact grid's marker. Only the split + * style's expanded form passes one — the paged style has no selection, and + * marking a day there would invent a state it doesn't have (#53). + */ + selected: LocalDate? = null, ) { Column( modifier = Modifier @@ -591,6 +601,7 @@ internal fun MonthGrid( inMonth = { it.month == month.month && it.year == month.year }, showWeekNumbers = showWeekNumbers, onOpenDay = onOpenDay, + selected = selected, modifier = Modifier .fillMaxWidth() .weight(1f), @@ -781,35 +792,79 @@ internal fun DenseMonthGrid( } } +/** Which way a drag went, decided once per gesture and then held. */ +private enum class DragAxis { Undecided, Horizontal, Vertical } + /** - * The month-changing horizontal swipe, shared by the paged and split styles. - * Accumulates the drag and commits past a threshold on release — the grid - * doesn't follow the finger, so there is no distance to rubber-band against. + * The month grid's drag gesture: horizontal pages the month, vertical expands or + * collapses the split style (#53). Accumulates and commits past a threshold on + * release — the grid doesn't follow the finger, so there is no distance to + * rubber-band against. * - * The threshold matches the week and day views'. It used to be 6dp, which is - * inside the distance a tap wanders: brushing the grid changed the month, and a - * page that turns on an unintended gesture reads as the animation misfiring - * rather than as the gesture being over-eager. + * The axis is **locked on the first movement and held for the whole gesture**, so + * a drag can page or expand but never both. Two independent detectors on one + * surface would each see their own component of a diagonal drag and both fire. + * + * The horizontal threshold matches the week and day views'. It used to be 6dp, + * which is inside the distance a tap wanders: brushing the grid changed the + * month, and a page that turns on an unintended gesture reads as the animation + * misfiring rather than as the gesture being over-eager. The vertical one is + * larger — swapping the whole layout out deserves a more deliberate pull than + * stepping to the next month. + * + * [onExpand]/[onCollapse] are null for the paged style, which leaves the vertical + * axis unclaimed: the lock still happens, so a vertical drag there does nothing + * rather than being re-read as a page turn. */ @Composable private fun rememberMonthSwipeModifier( onSwipeNext: () -> Unit, onSwipePrev: () -> Unit, + onExpand: (() -> Unit)? = null, + onCollapse: (() -> Unit)? = null, ): Modifier { - val threshold = with(LocalDensity.current) { MONTH_SWIPE_THRESHOLD.toPx() } - var dragAccum by remember { mutableFloatStateOf(0f) } - return Modifier.pointerInput(Unit) { - detectHorizontalDragGestures( - onDragStart = { dragAccum = 0f }, - onDragEnd = { - when { - dragAccum < -threshold -> onSwipeNext() - dragAccum > threshold -> onSwipePrev() - } - dragAccum = 0f + val density = LocalDensity.current + val pageThreshold = with(density) { MONTH_SWIPE_THRESHOLD.toPx() } + val expandThreshold = with(density) { MONTH_EXPAND_THRESHOLD.toPx() } + return Modifier.pointerInput(onSwipeNext, onSwipePrev, onExpand, onCollapse) { + var accum = Offset.Zero + var axis = DragAxis.Undecided + detectDragGestures( + onDragStart = { + accum = Offset.Zero + axis = DragAxis.Undecided + }, + onDragEnd = { + when (axis) { + DragAxis.Horizontal -> when { + accum.x < -pageThreshold -> onSwipeNext() + accum.x > pageThreshold -> onSwipePrev() + } + DragAxis.Vertical -> when { + accum.y > expandThreshold -> onExpand?.invoke() + accum.y < -expandThreshold -> onCollapse?.invoke() + } + DragAxis.Undecided -> Unit + } + accum = Offset.Zero + axis = DragAxis.Undecided + }, + onDragCancel = { + accum = Offset.Zero + axis = DragAxis.Undecided + }, + onDrag = { _, drag -> + accum += drag + if (axis == DragAxis.Undecided) { + // Ties go horizontal, keeping paging the default reading of an + // ambiguous drag as it was before the vertical axis existed. + axis = if (abs(accum.x) >= abs(accum.y)) { + DragAxis.Horizontal + } else { + DragAxis.Vertical + } + } }, - onDragCancel = { dragAccum = 0f }, - onHorizontalDrag = { _, drag -> dragAccum += drag }, ) } } @@ -817,14 +872,24 @@ private fun rememberMonthSwipeModifier( /** Drag distance that commits a month change, matching the week and day views. */ private val MONTH_SWIPE_THRESHOLD = 24.dp +/** Drag distance that commits an expand/collapse — deliberately longer than a page. */ +private val MONTH_EXPAND_THRESHOLD = 48.dp + /** * Split style content: the compact grid keeps the month swipe, the pane below it - * lists whatever day is selected. + * lists whatever day is selected — and a downward drag trades the pane away for + * the full paged grid, an upward one brings it back (#53). * * The grid slides between months like the paged style, which it can only do * because it always reserves [SPLIT_GRID_ROWS] rows. Sized to its own month it * stood 4–6 rows tall, so every swipe shunted the pane up or down by a row on * top of swapping the grid — the pane now holds still and only the grid moves. + * + * Expansion is deliberately **not** a stored preference. It is a way to look at + * the month you are on, not a fourth style; persisted, someone would expand it + * once and later find their Split style permanently changed with nothing on + * screen to explain why. [rememberSaveable] carries it across a rotation, which + * is as long as it should live. */ @Composable private fun SplitMonthContent( @@ -840,56 +905,196 @@ private fun SplitMonthContent( onEventClick: (EventInstance) -> Unit, onCreateEvent: (LocalDate) -> Unit, ) { - val slideSpec = rememberCalendarSlideSpec() val fadeSpec = rememberCalendarFadeSpec() - val reduceMotion = rememberReduceMotion() + var expanded by rememberSaveable { mutableStateOf(false) } // The swipe wraps the grid rather than living inside it: mid-transition // there are two grids, and the gesture belongs to neither. The pane is left // out of it — it scrolls and is full of tappable rows. - val swipeModifier = rememberMonthSwipeModifier(onSwipeNext, onSwipePrev) + val swipeModifier = rememberMonthSwipeModifier( + onSwipeNext = onSwipeNext, + onSwipePrev = onSwipePrev, + onExpand = { expanded = true }, + onCollapse = { expanded = false }, + ) when (state) { MonthUiState.Loading -> MonthGridLoading() is MonthUiState.Failure -> CalendarFailure(reason = state.reason, onRetry = onRetry) - is MonthUiState.Success -> Column(Modifier.fillMaxSize()) { - AnimatedContent( - // The selection travels *with* the state so each page keeps its - // own. Read from outside, both pages would show the incoming - // one, and paging visibly threw the marker across the outgoing - // grid — onto the new month's 1st, which the old grid still - // shows among its trailing days — before the new page arrived. - targetState = state to selected, - modifier = swipeModifier, - // Keyed on the month alone, so a provider notification refreshing - // the month you are on — or a tap moving the selection within it - // — updates in place instead of sliding. - contentKey = { (s, _) -> s.month }, - transitionSpec = { - calendarSlideTransition(slideDir, slideSpec, fadeSpec, reduceMotion) - }, - label = "split-month-transition", - ) { (s, sel) -> - SplitMonthGrid( - state = s, - selected = sel, + is MonthUiState.Success -> AnimatedContent( + targetState = expanded, + modifier = Modifier.fillMaxSize(), + // Both branches fill the same box — the grid grows into exactly the + // room the pane gives up — so there is no size change to contain. + transitionSpec = { fadeIn(fadeSpec) togetherWith fadeOut(fadeSpec) }, + label = "split-expand-transition", + ) { isExpanded -> + if (isExpanded) { + SplitMonthExpanded( + state = state, + selected = selected, + slideDir = slideDir, showWeekNumbers = showWeekNumbers, + swipeModifier = swipeModifier, + // A tap in the expanded grid picks the day and drops back, + // which gives the expanded month a job — a chooser you dip + // into — rather than a mode you can get stranded in. The + // collapse then runs with the selection already set, so the + // pane arrives showing the day you picked. + onPickDay = { + onSelectDay(it) + expanded = false + }, + onCollapse = { expanded = false }, + ) + } else { + SplitMonthCollapsed( + state = state, + selected = selected, + slideDir = slideDir, + showWeekNumbers = showWeekNumbers, + swipeModifier = swipeModifier, onSelectDay = onSelectDay, + onOpenDay = onOpenDay, + onEventClick = onEventClick, + onCreateEvent = onCreateEvent, + onExpand = { expanded = true }, ) } - SplitDayPane( - date = selected, - today = state.today, - // Null, not empty: the selection moves to the new month before - // its data arrives, and a missing key means "not loaded yet". - // Passing an empty list would claim the day was free. - events = state.instancesByDay[selected], - zone = state.zone, - onOpenDay = onOpenDay, - onEventClick = onEventClick, - onCreateEvent = onCreateEvent, - modifier = Modifier.weight(1f).fillMaxWidth(), + } + } +} + +/** The split style at rest: compact grid, handle, then the selected day's events. */ +@Composable +private fun SplitMonthCollapsed( + state: MonthUiState.Success, + selected: LocalDate, + slideDir: Int, + showWeekNumbers: Boolean, + swipeModifier: Modifier, + onSelectDay: (LocalDate) -> Unit, + onOpenDay: (LocalDate) -> Unit, + onEventClick: (EventInstance) -> Unit, + onCreateEvent: (LocalDate) -> Unit, + onExpand: () -> Unit, +) { + val slideSpec = rememberCalendarSlideSpec() + val fadeSpec = rememberCalendarFadeSpec() + val reduceMotion = rememberReduceMotion() + + Column(Modifier.fillMaxSize()) { + AnimatedContent( + // The selection travels *with* the state so each page keeps its + // own. Read from outside, both pages would show the incoming + // one, and paging visibly threw the marker across the outgoing + // grid — onto the new month's 1st, which the old grid still + // shows among its trailing days — before the new page arrived. + targetState = state to selected, + modifier = swipeModifier, + // Keyed on the month alone, so a provider notification refreshing + // the month you are on — or a tap moving the selection within it + // — updates in place instead of sliding. + contentKey = { (s, _) -> s.month }, + transitionSpec = { + calendarSlideTransition(slideDir, slideSpec, fadeSpec, reduceMotion) + }, + label = "split-month-transition", + ) { (s, sel) -> + SplitMonthGrid( + state = s, + selected = sel, + showWeekNumbers = showWeekNumbers, + onSelectDay = onSelectDay, ) } + SplitExpandHandle(expanded = false, onToggle = onExpand) + SplitDayPane( + date = selected, + today = state.today, + // Null, not empty: the selection moves to the new month before + // its data arrives, and a missing key means "not loaded yet". + // Passing an empty list would claim the day was free. + events = state.instancesByDay[selected], + zone = state.zone, + onOpenDay = onOpenDay, + onEventClick = onEventClick, + onCreateEvent = onCreateEvent, + modifier = Modifier.weight(1f).fillMaxWidth(), + ) + } +} + +/** + * The split style pulled open: the pane is gone and the month gets the whole + * screen in the paged style's own vocabulary — real event bars and pills instead + * of dots. The handle stays, now at the foot of the grid, to pull it back. + */ +@Composable +private fun SplitMonthExpanded( + state: MonthUiState.Success, + selected: LocalDate, + slideDir: Int, + showWeekNumbers: Boolean, + swipeModifier: Modifier, + onPickDay: (LocalDate) -> Unit, + onCollapse: () -> Unit, +) { + val slideSpec = rememberCalendarSlideSpec() + val fadeSpec = rememberCalendarFadeSpec() + val reduceMotion = rememberReduceMotion() + + Column(Modifier.fillMaxSize()) { + AnimatedContent( + targetState = state to selected, + modifier = Modifier.weight(1f).then(swipeModifier), + contentKey = { (s, _) -> s.month }, + transitionSpec = { + calendarSlideTransition(slideDir, slideSpec, fadeSpec, reduceMotion) + }, + label = "split-expanded-month-transition", + ) { (s, sel) -> + MonthGrid( + state = s, + showWeekNumbers = showWeekNumbers, + onOpenDay = onPickDay, + selected = sel, + ) + } + SplitExpandHandle(expanded = true, onToggle = onCollapse) + } +} + +/** + * The grab handle at the seam between grid and pane — the same M3 drag-handle + * pill a bottom sheet uses, for the same reason: it advertises that the surface + * moves. + * + * The drag itself lives on the grid, not here. This exists so the gesture is + * findable at all, and it takes taps too — a hidden swipe is no use to someone + * who never tries it, or who can't make the gesture. + */ +@Composable +private fun SplitExpandHandle( + expanded: Boolean, + onToggle: () -> Unit, + modifier: Modifier = Modifier, +) { + val label = stringResource( + if (expanded) R.string.month_split_collapse else R.string.month_split_expand, + ) + Box( + modifier = modifier + .fillMaxWidth() + .height(SPLIT_HANDLE_ROW_HEIGHT) + .clickable(onClick = onToggle) + .semantics { contentDescription = label }, + contentAlignment = Alignment.Center, + ) { + Box( + Modifier + .size(width = SPLIT_HANDLE_WIDTH, height = SPLIT_HANDLE_HEIGHT) + .background(MaterialTheme.colorScheme.outlineVariant, CircleShape), + ) } } @@ -913,6 +1118,14 @@ private val SPLIT_DOT_SIZE = 5.dp */ private const val SPLIT_GRID_ROWS = 6 +/** + * The expand handle: M3's drag-handle pill (32×4dp), in a row tall enough to be a + * comfortable tap target on its own. + */ +private val SPLIT_HANDLE_WIDTH = 32.dp +private val SPLIT_HANDLE_HEIGHT = 4.dp +private val SPLIT_HANDLE_ROW_HEIGHT = 24.dp + /** * The split style's grid (#53): the month compressed to day numbers and event * dots, with the selected day listed underneath by [SplitDayPane]. @@ -1290,6 +1503,8 @@ private fun MonthWeekRow( modifier: Modifier = Modifier, blankOutside: Boolean = false, labelMonthOnFirst: Boolean = false, + /** See [MonthGrid]'s `selected`; null for every style but expanded split. */ + selected: LocalDate? = null, ) { val dark = isSystemInDarkTheme() val laneCount = (week.spans.maxOfOrNull { it.lane } ?: -1) + 1 @@ -1334,6 +1549,21 @@ private fun MonthWeekRow( else -> MaterialTheme.colorScheme.surfaceContainerLow }, shape = CELL_SHAPE, + ) + // Scoped to the row's own month for the same reason the + // compact grid scopes it: a boundary week shows the + // neighbour month's dates too, and the marker belongs to + // exactly one of them. + .then( + if (d == selected && inMonth(d)) { + Modifier.border( + width = 1.5.dp, + color = MaterialTheme.colorScheme.primary, + shape = CELL_SHAPE, + ) + } else { + Modifier + }, ), ) } diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index bdd8be4..79c5155 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -385,6 +385,8 @@ Split A compact grid with dots for events, and the day you tap listed underneath. Nothing scheduled + Show the whole month + Show the day\'s events Quick-switch button Choose which views the top-right button cycles through, and drag to reorder them. Turned-off views stay reachable from the navigation menu. Navigation menu From 488de490f9f4880578f903cfd02ff977634c1430 Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Mon, 20 Jul 2026 19:36:13 +0200 Subject: [PATCH 56/78] feat(month): morph the split style's dots into the expanded grid (#53) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The expand cross-faded two unrelated grids, so a day's cell jumped from a 46dp row near the top of the screen to a full-height one halfway down and the dots simply vanished where bars appeared. It read as a swap, not as the month opening up. Tag the pieces that mean the same thing on both sides and let Compose carry them: each cell pill grows, its number rides along, and every dot travels out to the bar it stood for — which only works because the dots are now seated by lane, so each one has exactly one bar to become. The tags travel as a composition local with a null default, so the paged, continuous, and dense styles — which share these same row and cell composables — provide nothing and pay nothing. Reduced motion provides nothing either, leaving the plain cross-fade underneath. A multi-day bar is anchored on the day it starts in its row; the dots on the days it merely covers are left unmatched and fade where they stand while the bar sweeps out over them. Only one of them could become the bar without the others teleporting into it. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../calendula/ui/month/MonthMorph.kt | 98 ++++++++++ .../calendula/ui/month/MonthScreen.kt | 179 +++++++++++++----- 2 files changed, 226 insertions(+), 51 deletions(-) create mode 100644 app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthMorph.kt diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthMorph.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthMorph.kt new file mode 100644 index 0000000..1cd42c9 --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthMorph.kt @@ -0,0 +1,98 @@ +package de.jeanlucmakiola.calendula.ui.month + +import androidx.compose.animation.AnimatedVisibilityScope +import androidx.compose.animation.ExperimentalSharedTransitionApi +import androidx.compose.animation.SharedTransitionScope +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.runtime.Composable +import androidx.compose.runtime.compositionLocalOf +import androidx.compose.ui.Modifier +import kotlinx.datetime.LocalDate + +/** + * What lets the split style's compact grid *become* the full month rather than be + * swapped for it (#53). + * + * The two grids are separate composables — the compact one draws dots, the + * expanded one draws the paged style's bars and pills — so nothing about them is + * shared by construction. Tagging the pieces that mean the same thing on both + * sides with the same [MonthMorphKey] hands Compose enough to animate one into + * the other: a day's cell grows, its number rides along, and each dot travels out + * to the bar it stood for. + * + * It travels as a composition local rather than as parameters because the pieces + * sit five levels below where the scopes exist, and because a null default is + * exactly the right meaning for everyone else: the paged, continuous, and dense + * styles share the same row and cell composables, provide nothing, and pay + * nothing. Reduced motion provides nothing either, which leaves the plain + * cross-fade underneath. + */ +internal sealed interface MonthMorphKey { + /** A day's background pill — the structural anchor the rest rides on. */ + data class Cell(val date: LocalDate) : MonthMorphKey + + data class DayNumber(val date: LocalDate) : MonthMorphKey + + /** + * One event *on one day*. Keyed by date as well as instance because a + * multi-day event has a dot on every day it covers but only one bar, drawn + * from where it starts: the start day's dot is the one that becomes the bar, + * and the rest are left unmatched on purpose. They fade where they stand + * while the bar sweeps out over them, which is the honest reading — one of + * them could not become the bar without the others teleporting into it. + */ + data class Event(val date: LocalDate, val instanceId: Long) : MonthMorphKey + + /** The grab handle, which slides from the pane's seam to the foot of the grid. */ + data object Handle : MonthMorphKey +} + +@OptIn(ExperimentalSharedTransitionApi::class) +internal class MonthMorphScope( + val shared: SharedTransitionScope, + val visibility: AnimatedVisibilityScope, +) + +internal val LocalMonthMorph = compositionLocalOf { null } + +/** + * Tag content that is *the same thing* on both sides — a background pill, a day + * number — so it animates between its two positions and sizes. + */ +@OptIn(ExperimentalSharedTransitionApi::class) +@Composable +internal fun Modifier.morphElement(key: MonthMorphKey): Modifier { + val morph = LocalMonthMorph.current ?: return this + return with(morph.shared) { + this@morphElement.sharedElement( + sharedContentState = rememberSharedContentState(key), + animatedVisibilityScope = morph.visibility, + ) + } +} + +/** + * Tag content that means the same thing but *is drawn differently* on each side — + * a 5dp dot and a titled bar — so only the bounds are shared and the contents + * cross-fade inside them. + * + * [ResizeMode.RemeasureToBounds][SharedTransitionScope.ResizeMode] rather than + * scaling: a bar's title laid out at the dot's 5dp and then scaled up would + * arrive as a smear. Remeasuring keeps the text at its real size throughout and + * simply clips it while there is no room, so what grows is the pill, not the type. + */ +@OptIn(ExperimentalSharedTransitionApi::class) +@Composable +internal fun Modifier.morphBounds(key: MonthMorphKey): Modifier { + val morph = LocalMonthMorph.current ?: return this + return with(morph.shared) { + this@morphBounds.sharedBounds( + sharedContentState = rememberSharedContentState(key), + animatedVisibilityScope = morph.visibility, + enter = fadeIn(), + exit = fadeOut(), + resizeMode = SharedTransitionScope.ResizeMode.RemeasureToBounds, + ) + } +} diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthScreen.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthScreen.kt index 4ee8200..8dc6994 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthScreen.kt @@ -1,6 +1,8 @@ package de.jeanlucmakiola.calendula.ui.month import androidx.compose.animation.AnimatedContent +import androidx.compose.animation.ExperimentalSharedTransitionApi +import androidx.compose.animation.SharedTransitionLayout import androidx.compose.animation.core.RepeatMode import androidx.compose.animation.core.animateFloatAsState import androidx.compose.animation.core.snap @@ -891,6 +893,7 @@ private val MONTH_EXPAND_THRESHOLD = 48.dp * screen to explain why. [rememberSaveable] carries it across a rotation, which * is as long as it should live. */ +@OptIn(ExperimentalSharedTransitionApi::class) @Composable private fun SplitMonthContent( state: MonthUiState, @@ -906,6 +909,7 @@ private fun SplitMonthContent( onCreateEvent: (LocalDate) -> Unit, ) { val fadeSpec = rememberCalendarFadeSpec() + val reduceMotion = rememberReduceMotion() var expanded by rememberSaveable { mutableStateOf(false) } // The swipe wraps the grid rather than living inside it: mid-transition // there are two grids, and the gesture belongs to neither. The pane is left @@ -920,50 +924,93 @@ private fun SplitMonthContent( when (state) { MonthUiState.Loading -> MonthGridLoading() is MonthUiState.Failure -> CalendarFailure(reason = state.reason, onRetry = onRetry) - is MonthUiState.Success -> AnimatedContent( - targetState = expanded, - modifier = Modifier.fillMaxSize(), - // Both branches fill the same box — the grid grows into exactly the - // room the pane gives up — so there is no size change to contain. - transitionSpec = { fadeIn(fadeSpec) togetherWith fadeOut(fadeSpec) }, - label = "split-expand-transition", - ) { isExpanded -> - if (isExpanded) { - SplitMonthExpanded( - state = state, - selected = selected, - slideDir = slideDir, - showWeekNumbers = showWeekNumbers, - swipeModifier = swipeModifier, - // A tap in the expanded grid picks the day and drops back, - // which gives the expanded month a job — a chooser you dip - // into — rather than a mode you can get stranded in. The - // collapse then runs with the selection already set, so the - // pane arrives showing the day you picked. - onPickDay = { - onSelectDay(it) - expanded = false + is MonthUiState.Success -> SharedTransitionLayout(Modifier.fillMaxSize()) { + AnimatedContent( + targetState = expanded, + modifier = Modifier.fillMaxSize(), + // Both branches fill the same box — the grid grows into exactly the + // room the pane gives up — so there is no size change to contain. + // The travel between them is the shared elements' job, not a slide. + transitionSpec = { fadeIn(fadeSpec) togetherWith fadeOut(fadeSpec) }, + label = "split-expand-transition", + ) { isExpanded -> + CompositionLocalProvider( + // Null under reduced motion: nothing is tagged, nothing + // travels, and the cross-fade above is the whole transition. + LocalMonthMorph provides if (reduceMotion) { + null + } else { + MonthMorphScope(this@SharedTransitionLayout, this@AnimatedContent) }, - onCollapse = { expanded = false }, - ) - } else { - SplitMonthCollapsed( - state = state, - selected = selected, - slideDir = slideDir, - showWeekNumbers = showWeekNumbers, - swipeModifier = swipeModifier, - onSelectDay = onSelectDay, - onOpenDay = onOpenDay, - onEventClick = onEventClick, - onCreateEvent = onCreateEvent, - onExpand = { expanded = true }, - ) + ) { + SplitMonthBody( + expanded = isExpanded, + state = state, + selected = selected, + slideDir = slideDir, + showWeekNumbers = showWeekNumbers, + swipeModifier = swipeModifier, + onSelectDay = onSelectDay, + onOpenDay = onOpenDay, + onEventClick = onEventClick, + onCreateEvent = onCreateEvent, + onSetExpanded = { expanded = it }, + ) + } } } } } +/** The two faces of the split style, sharing one set of morph tags. */ +@Composable +private fun SplitMonthBody( + expanded: Boolean, + state: MonthUiState.Success, + selected: LocalDate, + slideDir: Int, + showWeekNumbers: Boolean, + swipeModifier: Modifier, + onSelectDay: (LocalDate) -> Unit, + onOpenDay: (LocalDate) -> Unit, + onEventClick: (EventInstance) -> Unit, + onCreateEvent: (LocalDate) -> Unit, + onSetExpanded: (Boolean) -> Unit, +) { + if (expanded) { + SplitMonthExpanded( + state = state, + selected = selected, + slideDir = slideDir, + showWeekNumbers = showWeekNumbers, + swipeModifier = swipeModifier, + // A tap in the expanded grid picks the day and drops back, which + // gives the expanded month a job — a chooser you dip into — rather + // than a mode you can get stranded in. The collapse then runs with + // the selection already set, so the pane arrives showing the day + // you picked. + onPickDay = { + onSelectDay(it) + onSetExpanded(false) + }, + onCollapse = { onSetExpanded(false) }, + ) + } else { + SplitMonthCollapsed( + state = state, + selected = selected, + slideDir = slideDir, + showWeekNumbers = showWeekNumbers, + swipeModifier = swipeModifier, + onSelectDay = onSelectDay, + onOpenDay = onOpenDay, + onEventClick = onEventClick, + onCreateEvent = onCreateEvent, + onExpand = { onSetExpanded(true) }, + ) + } +} + /** The split style at rest: compact grid, handle, then the selected day's events. */ @Composable private fun SplitMonthCollapsed( @@ -1093,6 +1140,9 @@ private fun SplitExpandHandle( Box( Modifier .size(width = SPLIT_HANDLE_WIDTH, height = SPLIT_HANDLE_HEIGHT) + // Tagged too, so it slides from the pane's seam down to the foot + // of the grid rather than blinking out at one and in at the other. + .morphElement(MonthMorphKey.Handle) .background(MaterialTheme.colorScheme.outlineVariant, CircleShape), ) } @@ -1234,19 +1284,28 @@ private fun SplitDayCell( animationSpec = if (reduceMotion) snap() else fadeSpec, label = "split-day-selection", ) + // Background and content are separate layers, mirroring how the paged row is + // built — and so the pill can be tagged for the morph on its own. Tagged as + // one piece with its contents inside, the dots would be nested shared + // elements within a shared element and travel twice. Box( modifier = modifier .padding(horizontal = CELL_GAP, vertical = 1.dp) - .clip(CELL_SHAPE) - .background(background) - .border( - width = 1.5.dp, - color = MaterialTheme.colorScheme.primary.copy(alpha = selection), - shape = CELL_SHAPE, - ) .selectable(selected = isSelected, onClick = onClick), contentAlignment = Alignment.TopCenter, ) { + Box( + Modifier + .fillMaxSize() + .morphElement(MonthMorphKey.Cell(date)) + .clip(CELL_SHAPE) + .background(background) + .border( + width = 1.5.dp, + color = MaterialTheme.colorScheme.primary.copy(alpha = selection), + shape = CELL_SHAPE, + ), + ) Column( modifier = Modifier.fillMaxSize().padding(top = 4.dp), horizontalAlignment = Alignment.CenterHorizontally, @@ -1255,10 +1314,10 @@ private fun SplitDayCell( date = date, isToday = isToday, inMonth = inMonth, - modifier = Modifier.fillMaxWidth(), + modifier = Modifier.fillMaxWidth().morphElement(MonthMorphKey.DayNumber(date)), ) Spacer(Modifier.height(2.dp)) - SplitDots(events = events, hidden = hidden, dark = dark) + SplitDots(date = date, events = events, hidden = hidden, dark = dark) } } } @@ -1272,7 +1331,7 @@ private fun SplitDayCell( * bar with no dot to grow out of. */ @Composable -private fun SplitDots(events: List, hidden: Int, dark: Boolean) { +private fun SplitDots(date: LocalDate, events: List, hidden: Int, dark: Boolean) { if (events.isEmpty()) return val soften = LocalSoftenColors.current Row( @@ -1280,9 +1339,13 @@ private fun SplitDots(events: List, hidden: Int, dark: Boolean) { verticalAlignment = Alignment.CenterVertically, ) { events.forEach { event -> + // The dot keeps its own circle and the bar its 4dp corners; they + // cross-fade inside shared bounds, and at the dot's size the two + // radii are a fraction of a pixel apart. Box( modifier = Modifier .size(SPLIT_DOT_SIZE) + .morphBounds(MonthMorphKey.Event(date, event.instanceId)) .background(eventFill(event.color, dark, soften), CircleShape), ) } @@ -1540,6 +1603,7 @@ private fun MonthWeekRow( .weight(1f) .fillMaxHeight() .padding(horizontal = CELL_GAP, vertical = 1.dp) + .morphElement(MonthMorphKey.Cell(d)) .background( color = when { inMonth(d) -> MaterialTheme.colorScheme.surfaceContainer @@ -1586,7 +1650,9 @@ private fun MonthWeekRow( } else { null }, - modifier = Modifier.weight(1f), + modifier = Modifier + .weight(1f) + .morphElement(MonthMorphKey.DayNumber(d)), ) } } @@ -1615,7 +1681,17 @@ private fun MonthWeekRow( ) .width(colW * cols) .height(EVENT_ROW_HEIGHT) - .padding(horizontal = CELL_GAP + 1.dp, vertical = 1.dp), + .padding(horizontal = CELL_GAP + 1.dp, vertical = 1.dp) + // Anchored on the day the bar starts *in this row*, + // which is where its dot sat. A bar carried in from + // the previous week starts at column 0, and column + // 0's dot is the one that grows into it. + .morphBounds( + MonthMorphKey.Event( + date = week.days[span.startCol], + instanceId = span.event.instanceId, + ), + ), ) } // Single-day timed pills + overflow, per column. Pills fill the @@ -1643,7 +1719,8 @@ private fun MonthWeekRow( ) .width(colW) .height(EVENT_ROW_HEIGHT) - .padding(horizontal = CELL_GAP + 1.dp, vertical = 1.dp), + .padding(horizontal = CELL_GAP + 1.dp, vertical = 1.dp) + .morphBounds(MonthMorphKey.Event(d, ev.instanceId)), ) } val hidden = (week.countByDay[d] ?: 0) - occupied.size - pillsShown.size From 0df420e551d0d15d5b6d253a62c7d3536cc2abdc Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Mon, 20 Jul 2026 19:37:44 +0200 Subject: [PATCH 57/78] feat(month): collapse the expanded month on back (#53) Expanding replaces the whole screen, so it is a place you can be, and every other place in the app can be backed out of. Without this, back left the Month view entirely and coming back found it still expanded. Declared deeper than CalendarHost's view-stack handler, which is what makes it take precedence while it is enabled. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../de/jeanlucmakiola/calendula/ui/month/MonthScreen.kt | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthScreen.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthScreen.kt index 8dc6994..3ce54af 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthScreen.kt @@ -1,5 +1,6 @@ package de.jeanlucmakiola.calendula.ui.month +import androidx.activity.compose.BackHandler import androidx.compose.animation.AnimatedContent import androidx.compose.animation.ExperimentalSharedTransitionApi import androidx.compose.animation.SharedTransitionLayout @@ -911,6 +912,11 @@ private fun SplitMonthContent( val fadeSpec = rememberCalendarFadeSpec() val reduceMotion = rememberReduceMotion() var expanded by rememberSaveable { mutableStateOf(false) } + // Back collapses before it does anything else. Expanding replaces the whole + // screen, so it is a place you can be, and every other place in the app can + // be backed out of. Declared deeper than CalendarHost's view-stack handler, + // which is what makes it win while it is enabled. + BackHandler(enabled = expanded) { expanded = false } // The swipe wraps the grid rather than living inside it: mid-transition // there are two grids, and the gesture belongs to neither. The pane is left // out of it — it scrolls and is full of tappable rows. From f70b33b86453e721f6ba461a86106afaac3b2ee7 Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Mon, 20 Jul 2026 19:48:03 +0200 Subject: [PATCH 58/78] fix(month): drop the expanded grid's outline and make the pull fire sooner (#53) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three things from the first on-device look. The selection outline is gone from the expanded grid. Once the cells carry real event bars it was one mark too many, and it had nothing to say — every day there is one tap from being selected, and tapping is what closes the view. MonthGrid/MonthWeekRow lose the parameter again. Expand and collapse now fire the instant the drag clears the threshold instead of on release. Waiting for the lift left the screen inert under a finger that had already asked for the thing, which read as the gesture being slow to take rather than as a deliberate commit. Paging still waits, because there the gesture is genuinely undecided until you let go — you can drag back and settle on a different month. Between expanded and collapsed there is nothing to change your mind about. The morph itself runs on the motion scheme's *fast* specs rather than the page slide's default ones. Paging is a spring under one whole page and wants room to settle; this answers a drag already committed to, and with a hundred-odd pieces moving at once a long settle reads as lag. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../calendula/ui/month/MonthMorph.kt | 23 ++++++ .../calendula/ui/month/MonthScreen.kt | 79 ++++++++++--------- 2 files changed, 63 insertions(+), 39 deletions(-) diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthMorph.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthMorph.kt index 1cd42c9..ec05cd2 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthMorph.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthMorph.kt @@ -1,13 +1,18 @@ package de.jeanlucmakiola.calendula.ui.month import androidx.compose.animation.AnimatedVisibilityScope +import androidx.compose.animation.BoundsTransform import androidx.compose.animation.ExperimentalSharedTransitionApi import androidx.compose.animation.SharedTransitionScope import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut +import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi +import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable import androidx.compose.runtime.compositionLocalOf +import androidx.compose.runtime.remember import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Rect import kotlinx.datetime.LocalDate /** @@ -56,6 +61,22 @@ internal class MonthMorphScope( internal val LocalMonthMorph = compositionLocalOf { null } +/** + * How fast a tagged piece travels between its two homes. + * + * The *fast* spatial spec, not the default one the month/week/day page slide + * uses. Paging is a spring under one whole page and wants room to settle; this is + * a direct answer to a drag the user has already committed to, and at the default + * pace the grid felt like it was catching up rather than responding. It is also + * a hundred-odd pieces moving at once, where a long settle reads as lag. + */ +@OptIn(ExperimentalSharedTransitionApi::class, ExperimentalMaterial3ExpressiveApi::class) +@Composable +internal fun rememberMorphBoundsTransform(): BoundsTransform { + val spec = MaterialTheme.motionScheme.fastSpatialSpec() + return remember(spec) { BoundsTransform { _, _ -> spec } } +} + /** * Tag content that is *the same thing* on both sides — a background pill, a day * number — so it animates between its two positions and sizes. @@ -68,6 +89,7 @@ internal fun Modifier.morphElement(key: MonthMorphKey): Modifier { this@morphElement.sharedElement( sharedContentState = rememberSharedContentState(key), animatedVisibilityScope = morph.visibility, + boundsTransform = rememberMorphBoundsTransform(), ) } } @@ -92,6 +114,7 @@ internal fun Modifier.morphBounds(key: MonthMorphKey): Modifier { animatedVisibilityScope = morph.visibility, enter = fadeIn(), exit = fadeOut(), + boundsTransform = rememberMorphBoundsTransform(), resizeMode = SharedTransitionScope.ResizeMode.RemeasureToBounds, ) } diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthScreen.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthScreen.kt index 3ce54af..e1ec215 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthScreen.kt @@ -47,6 +47,7 @@ import androidx.compose.material.icons.filled.Menu import androidx.compose.material.icons.filled.Search import androidx.compose.material3.DrawerValue import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.material3.IconButton @@ -580,12 +581,6 @@ internal fun MonthGrid( state: MonthUiState.Success, showWeekNumbers: Boolean, onOpenDay: (LocalDate) -> Unit, - /** - * Outlined when set, matching the compact grid's marker. Only the split - * style's expanded form passes one — the paged style has no selection, and - * marking a day there would invent a state it doesn't have (#53). - */ - selected: LocalDate? = null, ) { Column( modifier = Modifier @@ -604,7 +599,6 @@ internal fun MonthGrid( inMonth = { it.month == month.month && it.year == month.year }, showWeekNumbers = showWeekNumbers, onOpenDay = onOpenDay, - selected = selected, modifier = Modifier .fillMaxWidth() .weight(1f), @@ -832,29 +826,29 @@ private fun rememberMonthSwipeModifier( return Modifier.pointerInput(onSwipeNext, onSwipePrev, onExpand, onCollapse) { var accum = Offset.Zero var axis = DragAxis.Undecided + var fired = false detectDragGestures( onDragStart = { accum = Offset.Zero axis = DragAxis.Undecided + fired = false }, onDragEnd = { - when (axis) { - DragAxis.Horizontal -> when { + // Only paging waits for the lift; see onDrag. + if (!fired && axis == DragAxis.Horizontal) { + when { accum.x < -pageThreshold -> onSwipeNext() accum.x > pageThreshold -> onSwipePrev() } - DragAxis.Vertical -> when { - accum.y > expandThreshold -> onExpand?.invoke() - accum.y < -expandThreshold -> onCollapse?.invoke() - } - DragAxis.Undecided -> Unit } accum = Offset.Zero axis = DragAxis.Undecided + fired = false }, onDragCancel = { accum = Offset.Zero axis = DragAxis.Undecided + fired = false }, onDrag = { _, drag -> accum += drag @@ -867,6 +861,27 @@ private fun rememberMonthSwipeModifier( DragAxis.Vertical } } + // Expand and collapse fire the instant the drag clears the + // threshold, rather than on release. Waiting for the lift left the + // screen inert under a finger that had already asked for the thing, + // and the answer only arrived once you let go — which read as the + // gesture being slow to take rather than as a deliberate commit. + // + // Paging still waits, because there the pending gesture is + // genuinely undecided: you can drag back the other way and settle + // on a different month. There is nothing between expanded and + // collapsed to change your mind about. + if (!fired && axis == DragAxis.Vertical) { + val commit = when { + accum.y > expandThreshold -> onExpand + accum.y < -expandThreshold -> onCollapse + else -> null + } + if (commit != null) { + fired = true + commit() + } + } }, ) } @@ -894,7 +909,7 @@ private val MONTH_EXPAND_THRESHOLD = 48.dp * screen to explain why. [rememberSaveable] carries it across a rotation, which * is as long as it should live. */ -@OptIn(ExperimentalSharedTransitionApi::class) +@OptIn(ExperimentalSharedTransitionApi::class, ExperimentalMaterial3ExpressiveApi::class) @Composable private fun SplitMonthContent( state: MonthUiState, @@ -909,7 +924,9 @@ private fun SplitMonthContent( onEventClick: (EventInstance) -> Unit, onCreateEvent: (LocalDate) -> Unit, ) { - val fadeSpec = rememberCalendarFadeSpec() + // The fast effects spec, matching the morph's fast spatial one — the page + // slide's default pace made the swap trail behind a gesture already committed. + val fadeSpec = MaterialTheme.motionScheme.fastEffectsSpec() val reduceMotion = rememberReduceMotion() var expanded by rememberSaveable { mutableStateOf(false) } // Back collapses before it does anything else. Expanding replaces the whole @@ -986,7 +1003,6 @@ private fun SplitMonthBody( if (expanded) { SplitMonthExpanded( state = state, - selected = selected, slideDir = slideDir, showWeekNumbers = showWeekNumbers, swipeModifier = swipeModifier, @@ -1081,11 +1097,14 @@ private fun SplitMonthCollapsed( * The split style pulled open: the pane is gone and the month gets the whole * screen in the paged style's own vocabulary — real event bars and pills instead * of dots. The handle stays, now at the foot of the grid, to pull it back. + * + * No selection marker here, deliberately. Once the cells carry real event bars + * the outline is one mark too many, and it has nothing to say: every day is a tap + * away from being the selected one, and tapping is what closes this view. */ @Composable private fun SplitMonthExpanded( state: MonthUiState.Success, - selected: LocalDate, slideDir: Int, showWeekNumbers: Boolean, swipeModifier: Modifier, @@ -1098,19 +1117,18 @@ private fun SplitMonthExpanded( Column(Modifier.fillMaxSize()) { AnimatedContent( - targetState = state to selected, + targetState = state, modifier = Modifier.weight(1f).then(swipeModifier), - contentKey = { (s, _) -> s.month }, + contentKey = { it.month }, transitionSpec = { calendarSlideTransition(slideDir, slideSpec, fadeSpec, reduceMotion) }, label = "split-expanded-month-transition", - ) { (s, sel) -> + ) { s -> MonthGrid( state = s, showWeekNumbers = showWeekNumbers, onOpenDay = onPickDay, - selected = sel, ) } SplitExpandHandle(expanded = true, onToggle = onCollapse) @@ -1572,8 +1590,6 @@ private fun MonthWeekRow( modifier: Modifier = Modifier, blankOutside: Boolean = false, labelMonthOnFirst: Boolean = false, - /** See [MonthGrid]'s `selected`; null for every style but expanded split. */ - selected: LocalDate? = null, ) { val dark = isSystemInDarkTheme() val laneCount = (week.spans.maxOfOrNull { it.lane } ?: -1) + 1 @@ -1619,21 +1635,6 @@ private fun MonthWeekRow( else -> MaterialTheme.colorScheme.surfaceContainerLow }, shape = CELL_SHAPE, - ) - // Scoped to the row's own month for the same reason the - // compact grid scopes it: a boundary week shows the - // neighbour month's dates too, and the marker belongs to - // exactly one of them. - .then( - if (d == selected && inMonth(d)) { - Modifier.border( - width = 1.5.dp, - color = MaterialTheme.colorScheme.primary, - shape = CELL_SHAPE, - ) - } else { - Modifier - }, ), ) } From 01a6c8bab8b1489924090eb45f3aabc909e0f701 Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Mon, 20 Jul 2026 19:51:57 +0200 Subject: [PATCH 59/78] revert(month): put the morph back on the calendar's own motion specs (#53) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The trigger was what felt slow, and firing on the threshold instead of on release already fixed that. Speeding up the animation on top of it was a second change to the same complaint, and not one that was asked for — the morph goes back to the shared calendar slide and fade specs, so it moves at the same pace as every other transition in the app. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../calendula/ui/month/MonthMorph.kt | 23 ------------------- .../calendula/ui/month/MonthScreen.kt | 7 ++---- 2 files changed, 2 insertions(+), 28 deletions(-) diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthMorph.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthMorph.kt index ec05cd2..1cd42c9 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthMorph.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthMorph.kt @@ -1,18 +1,13 @@ package de.jeanlucmakiola.calendula.ui.month import androidx.compose.animation.AnimatedVisibilityScope -import androidx.compose.animation.BoundsTransform import androidx.compose.animation.ExperimentalSharedTransitionApi import androidx.compose.animation.SharedTransitionScope import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut -import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi -import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable import androidx.compose.runtime.compositionLocalOf -import androidx.compose.runtime.remember import androidx.compose.ui.Modifier -import androidx.compose.ui.geometry.Rect import kotlinx.datetime.LocalDate /** @@ -61,22 +56,6 @@ internal class MonthMorphScope( internal val LocalMonthMorph = compositionLocalOf { null } -/** - * How fast a tagged piece travels between its two homes. - * - * The *fast* spatial spec, not the default one the month/week/day page slide - * uses. Paging is a spring under one whole page and wants room to settle; this is - * a direct answer to a drag the user has already committed to, and at the default - * pace the grid felt like it was catching up rather than responding. It is also - * a hundred-odd pieces moving at once, where a long settle reads as lag. - */ -@OptIn(ExperimentalSharedTransitionApi::class, ExperimentalMaterial3ExpressiveApi::class) -@Composable -internal fun rememberMorphBoundsTransform(): BoundsTransform { - val spec = MaterialTheme.motionScheme.fastSpatialSpec() - return remember(spec) { BoundsTransform { _, _ -> spec } } -} - /** * Tag content that is *the same thing* on both sides — a background pill, a day * number — so it animates between its two positions and sizes. @@ -89,7 +68,6 @@ internal fun Modifier.morphElement(key: MonthMorphKey): Modifier { this@morphElement.sharedElement( sharedContentState = rememberSharedContentState(key), animatedVisibilityScope = morph.visibility, - boundsTransform = rememberMorphBoundsTransform(), ) } } @@ -114,7 +92,6 @@ internal fun Modifier.morphBounds(key: MonthMorphKey): Modifier { animatedVisibilityScope = morph.visibility, enter = fadeIn(), exit = fadeOut(), - boundsTransform = rememberMorphBoundsTransform(), resizeMode = SharedTransitionScope.ResizeMode.RemeasureToBounds, ) } diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthScreen.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthScreen.kt index e1ec215..c8dc4af 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthScreen.kt @@ -47,7 +47,6 @@ import androidx.compose.material.icons.filled.Menu import androidx.compose.material.icons.filled.Search import androidx.compose.material3.DrawerValue import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.material3.IconButton @@ -909,7 +908,7 @@ private val MONTH_EXPAND_THRESHOLD = 48.dp * screen to explain why. [rememberSaveable] carries it across a rotation, which * is as long as it should live. */ -@OptIn(ExperimentalSharedTransitionApi::class, ExperimentalMaterial3ExpressiveApi::class) +@OptIn(ExperimentalSharedTransitionApi::class) @Composable private fun SplitMonthContent( state: MonthUiState, @@ -924,9 +923,7 @@ private fun SplitMonthContent( onEventClick: (EventInstance) -> Unit, onCreateEvent: (LocalDate) -> Unit, ) { - // The fast effects spec, matching the morph's fast spatial one — the page - // slide's default pace made the swap trail behind a gesture already committed. - val fadeSpec = MaterialTheme.motionScheme.fastEffectsSpec() + val fadeSpec = rememberCalendarFadeSpec() val reduceMotion = rememberReduceMotion() var expanded by rememberSaveable { mutableStateOf(false) } // Back collapses before it does anything else. Expanding replaces the whole From 63ba69729e50cf4a777a71294e49847de1ec8a42 Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Mon, 20 Jul 2026 19:57:29 +0200 Subject: [PATCH 60/78] fix(month): keep the selection outline off the morphing cell (#53) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Drawn on the cell's morphing pill, the outline rode its shared bounds — so collapsing painted it at the *expanded* cell's size and shrank it down, a full-height outline flashing over a grid that no longer had full-height cells. The same shape of mistake as the marker that used to cross the outgoing month during a page turn. It gets its own untagged layer, laid out where the collapsed cell actually is, and fades in there. That is the only size it is ever true at. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../jeanlucmakiola/calendula/ui/month/MonthScreen.kt | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthScreen.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthScreen.kt index c8dc4af..a8d1882 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthScreen.kt @@ -1320,7 +1320,17 @@ private fun SplitDayCell( .fillMaxSize() .morphElement(MonthMorphKey.Cell(date)) .clip(CELL_SHAPE) - .background(background) + .background(background), + ) + // The outline is a layer of its own, deliberately *untagged*. Drawn on the + // morphing pill it rode the cell's shared bounds, so collapsing painted it + // at the expanded cell's size and shrank it down — a full-height outline + // flashing over a grid that no longer had full-height cells. Untagged it + // is laid out where the collapsed cell actually is and simply fades in, + // which is the only size it is ever true at. + Box( + Modifier + .fillMaxSize() .border( width = 1.5.dp, color = MaterialTheme.colorScheme.primary.copy(alpha = selection), From beb8536e8ab567def5c5d706106d04d3009c2c59 Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Mon, 20 Jul 2026 20:24:52 +0200 Subject: [PATCH 61/78] feat(calendar): commit page swipes on the threshold, not on release (#53) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The split style's pull-to-expand fires the moment the drag clears its threshold, and that turned out to be the better feel — so paging now does the same everywhere: month, week and day. Waiting for the lift meant the page sat still under a finger that had already travelled far enough to ask for it, and the answer only arrived once you let go, which reads as the view being slow rather than as a deliberate commit. The trade is that a drag can no longer be taken back by dragging the other way; once you have moved 24dp deliberately you meant it, and the page you left is one swipe back. The three views had three copies of the same detector, so this extracts one rememberCalendarPageSwipe and the threshold constant beside it. It stays horizontal-only on purpose: the week and day timelines scroll vertically underneath, and a two-dimensional detector would claim those drags before the inner scroll saw them. The month keeps its own axis-locking detector because the split style needs the vertical axis, and now shares the threshold. Also fixes the selection outline shivering rather than fading. Its animated alpha was read while composing the cell, so every frame of the fade recomposed all 42 cells at once — the fade was driving the whole grid through recomposition to change one colour. It is now held as a State and read inside a drawBehind, which keeps it in the draw phase where it touches nothing but pixels. That means drawing the rounded rect by hand instead of Modifier.border, so the corner radius is named alongside CELL_SHAPE to stop the two drifting. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../calendula/ui/common/CalendarSwipe.kt | 79 ++++++++++++ .../calendula/ui/day/DayScreen.kt | 20 +--- .../calendula/ui/month/MonthScreen.kt | 113 +++++++++++------- .../calendula/ui/week/WeekScreen.kt | 20 +--- 4 files changed, 151 insertions(+), 81 deletions(-) create mode 100644 app/src/main/java/de/jeanlucmakiola/calendula/ui/common/CalendarSwipe.kt diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/CalendarSwipe.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/CalendarSwipe.kt new file mode 100644 index 0000000..dc15d03 --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/CalendarSwipe.kt @@ -0,0 +1,79 @@ +package de.jeanlucmakiola.calendula.ui.common + +import androidx.compose.foundation.gestures.detectHorizontalDragGestures +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.unit.dp + +/** + * Drag distance that turns a calendar page, shared by the month, week and day + * views so all three answer a swipe at the same point. + * + * It was 6dp once, which is inside the distance a tap wanders: brushing the grid + * changed the month, and a page that turns on an unintended gesture reads as the + * animation misfiring rather than as the gesture being over-eager. + */ +val CALENDAR_SWIPE_THRESHOLD = 24.dp + +/** + * The whole-page horizontal swipe: one page per gesture, committed **the moment + * the drag clears [CALENDAR_SWIPE_THRESHOLD]** rather than when the finger lifts. + * + * Waiting for the lift meant the page sat still under a finger that had already + * travelled far enough to ask for it, and the answer only arrived once you let + * go — which reads as the view being slow rather than as a deliberate commit. + * Firing on the threshold is what makes the gesture feel like it is being + * followed. The trade is that a drag can no longer be taken back by dragging the + * other way; in practice, once you have moved 24dp deliberately you meant it, and + * the page you land on is one swipe back. + * + * Deliberately **horizontal-only**. The week and day timelines scroll vertically + * underneath this, and a two-dimensional detector here would claim those drags + * before the inner scroll ever saw them. As it is, a horizontal drag crosses this + * detector's slop while a vertical one is consumed below, and the two coexist. + * (The month view's split style needs a vertical axis as well, so it keeps its + * own axis-locking detector rather than using this.) + */ +@Composable +fun rememberCalendarPageSwipe( + onSwipeNext: () -> Unit, + onSwipePrev: () -> Unit, +): Modifier { + val threshold = with(LocalDensity.current) { CALENDAR_SWIPE_THRESHOLD.toPx() } + return Modifier.pointerInput(onSwipeNext, onSwipePrev) { + var accum = 0f + // One page per gesture: without this a long drag would keep re-firing + // every time the accumulator crossed the threshold again. + var fired = false + detectHorizontalDragGestures( + onDragStart = { + accum = 0f + fired = false + }, + onDragEnd = { + accum = 0f + fired = false + }, + onDragCancel = { + accum = 0f + fired = false + }, + onHorizontalDrag = { _, drag -> + accum += drag + if (!fired) { + val commit = when { + accum < -threshold -> onSwipeNext + accum > threshold -> onSwipePrev + else -> null + } + if (commit != null) { + fired = true + commit() + } + } + }, + ) + } +} diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/day/DayScreen.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/day/DayScreen.kt index 4526677..842486e 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/day/DayScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/day/DayScreen.kt @@ -5,7 +5,6 @@ import androidx.compose.animation.animateColorAsState import androidx.compose.animation.core.animateDpAsState import androidx.compose.foundation.background import androidx.compose.foundation.clickable -import androidx.compose.foundation.gestures.detectHorizontalDragGestures import androidx.compose.foundation.gestures.detectTapGestures import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.foundation.layout.Box @@ -43,7 +42,6 @@ import androidx.compose.material3.rememberDrawerState import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableFloatStateOf import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope @@ -79,6 +77,7 @@ import de.jeanlucmakiola.calendula.ui.common.NowLine import de.jeanlucmakiola.calendula.ui.common.ViewSwitcherPill import de.jeanlucmakiola.calendula.ui.common.calendarSlideTransition import de.jeanlucmakiola.calendula.ui.common.rememberCalendarFadeSpec +import de.jeanlucmakiola.calendula.ui.common.rememberCalendarPageSwipe import de.jeanlucmakiola.floret.identity.rememberReduceMotion import de.jeanlucmakiola.calendula.ui.common.next import de.jeanlucmakiola.calendula.ui.common.LocalSoftenColors @@ -266,8 +265,6 @@ private fun DayContent( modifier: Modifier = Modifier, ) { val density = LocalDensity.current - val threshold = with(density) { 24.dp.toPx() } - var dragAccum by remember { mutableFloatStateOf(0f) } val slideSpec = rememberCalendarSlideSpec() val fadeSpec = rememberCalendarFadeSpec() val reduceMotion = rememberReduceMotion() @@ -296,20 +293,7 @@ private fun DayContent( // Whole-page horizontal swipe, one level above the timeline's vertical // scroll: a horizontal drag crosses this detector's slop, while a vertical // drag is consumed by the inner scroll first — the two gestures coexist. - val swipeModifier = Modifier.pointerInput(Unit) { - detectHorizontalDragGestures( - onDragStart = { dragAccum = 0f }, - onDragEnd = { - when { - dragAccum < -threshold -> onSwipeNext() - dragAccum > threshold -> onSwipePrev() - } - dragAccum = 0f - }, - onDragCancel = { dragAccum = 0f }, - onHorizontalDrag = { _, drag -> dragAccum += drag }, - ) - } + val swipeModifier = rememberCalendarPageSwipe(onSwipeNext, onSwipePrev) AnimatedContent( targetState = state, diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthScreen.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthScreen.kt index a8d1882..c504759 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthScreen.kt @@ -75,8 +75,12 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.draw.alpha import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clipToBounds +import androidx.compose.ui.draw.drawBehind +import androidx.compose.ui.geometry.CornerRadius import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Size import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.drawscope.Stroke import androidx.compose.ui.input.nestedscroll.nestedScroll import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.platform.LocalDensity @@ -100,6 +104,7 @@ import de.jeanlucmakiola.calendula.ui.common.CalendarDrawer import de.jeanlucmakiola.calendula.ui.common.CalendarFabColumn import de.jeanlucmakiola.calendula.ui.common.TodayAction import de.jeanlucmakiola.calendula.ui.common.CalendarFailure +import de.jeanlucmakiola.calendula.ui.common.CALENDAR_SWIPE_THRESHOLD import de.jeanlucmakiola.calendula.ui.common.CalendarView import de.jeanlucmakiola.calendula.ui.common.IMPLEMENTED_VIEWS import de.jeanlucmakiola.calendula.ui.common.EventDimAlpha @@ -556,7 +561,13 @@ private val WEEK_NUMBER_GUTTER = 40.dp private val DAY_NUMBER_GAP = 4.dp private val CELL_TOP_PADDING = 6.dp private val CELL_GAP = 2.dp -private val CELL_SHAPE = RoundedCornerShape(12.dp) +/** Named separately because the split style's selection outline draws its own + * rounded rect and has to match this radius exactly. */ +private val CELL_CORNER = 12.dp +private val CELL_SHAPE = RoundedCornerShape(CELL_CORNER) + +/** Width of the split style's selected-day outline. */ +private val SPLIT_SELECTION_STROKE = 1.5.dp private const val MAX_EVENT_ROWS = 3 /** @@ -793,20 +804,21 @@ private enum class DragAxis { Undecided, Horizontal, Vertical } /** * The month grid's drag gesture: horizontal pages the month, vertical expands or - * collapses the split style (#53). Accumulates and commits past a threshold on - * release — the grid doesn't follow the finger, so there is no distance to - * rubber-band against. + * collapses the split style (#53). The grid doesn't follow the finger, so there is + * no distance to rubber-band against — it commits the moment the accumulated drag + * clears the threshold, once per gesture, exactly as + * [rememberCalendarPageSwipe][de.jeanlucmakiola.calendula.ui.common.rememberCalendarPageSwipe] + * does for the week and day views. * - * The axis is **locked on the first movement and held for the whole gesture**, so - * a drag can page or expand but never both. Two independent detectors on one - * surface would each see their own component of a diagonal drag and both fire. + * This is the month's own detector rather than that shared one because it needs + * two axes, and the axis is **locked on the first movement and held for the whole + * gesture** so a drag can page or expand but never both. Two independent + * detectors on one surface would each see their own component of a diagonal drag + * and both fire. * - * The horizontal threshold matches the week and day views'. It used to be 6dp, - * which is inside the distance a tap wanders: brushing the grid changed the - * month, and a page that turns on an unintended gesture reads as the animation - * misfiring rather than as the gesture being over-eager. The vertical one is - * larger — swapping the whole layout out deserves a more deliberate pull than - * stepping to the next month. + * The horizontal threshold is the shared one. The vertical is larger — swapping + * the whole layout out deserves a more deliberate pull than stepping to the next + * month. * * [onExpand]/[onCollapse] are null for the paged style, which leaves the vertical * axis unclaimed: the lock still happens, so a vertical drag there does nothing @@ -820,7 +832,7 @@ private fun rememberMonthSwipeModifier( onCollapse: (() -> Unit)? = null, ): Modifier { val density = LocalDensity.current - val pageThreshold = with(density) { MONTH_SWIPE_THRESHOLD.toPx() } + val pageThreshold = with(density) { CALENDAR_SWIPE_THRESHOLD.toPx() } val expandThreshold = with(density) { MONTH_EXPAND_THRESHOLD.toPx() } return Modifier.pointerInput(onSwipeNext, onSwipePrev, onExpand, onCollapse) { var accum = Offset.Zero @@ -833,13 +845,6 @@ private fun rememberMonthSwipeModifier( fired = false }, onDragEnd = { - // Only paging waits for the lift; see onDrag. - if (!fired && axis == DragAxis.Horizontal) { - when { - accum.x < -pageThreshold -> onSwipeNext() - accum.x > pageThreshold -> onSwipePrev() - } - } accum = Offset.Zero axis = DragAxis.Undecided fired = false @@ -860,21 +865,22 @@ private fun rememberMonthSwipeModifier( DragAxis.Vertical } } - // Expand and collapse fire the instant the drag clears the - // threshold, rather than on release. Waiting for the lift left the - // screen inert under a finger that had already asked for the thing, - // and the answer only arrived once you let go — which read as the - // gesture being slow to take rather than as a deliberate commit. - // - // Paging still waits, because there the pending gesture is - // genuinely undecided: you can drag back the other way and settle - // on a different month. There is nothing between expanded and - // collapsed to change your mind about. - if (!fired && axis == DragAxis.Vertical) { - val commit = when { - accum.y > expandThreshold -> onExpand - accum.y < -expandThreshold -> onCollapse - else -> null + // Both axes commit the instant the drag clears their threshold, + // rather than on release — see [rememberCalendarPageSwipe], which + // does the same for the week and day views. + if (!fired) { + val commit = when (axis) { + DragAxis.Horizontal -> when { + accum.x < -pageThreshold -> onSwipeNext + accum.x > pageThreshold -> onSwipePrev + else -> null + } + DragAxis.Vertical -> when { + accum.y > expandThreshold -> onExpand + accum.y < -expandThreshold -> onCollapse + else -> null + } + DragAxis.Undecided -> null } if (commit != null) { fired = true @@ -886,9 +892,6 @@ private fun rememberMonthSwipeModifier( } } -/** Drag distance that commits a month change, matching the week and day views. */ -private val MONTH_SWIPE_THRESHOLD = 24.dp - /** Drag distance that commits an expand/collapse — deliberately longer than a page. */ private val MONTH_EXPAND_THRESHOLD = 48.dp @@ -1298,13 +1301,22 @@ private fun SplitDayCell( // Each cell fades its own outline, so moving the selection reads as one // mark crossing the grid rather than as an outline blinking out here and // reappearing there. Snapped under reduced motion. + // + // Held as a State and read *inside the draw lambda below*, never unwrapped + // here. Read in composition it made every frame of the fade recompose all + // 42 cells at once, which is what made the outline shiver rather than fade. + // Kept in the draw phase, the animation touches nothing but the pixels. val reduceMotion = rememberReduceMotion() val fadeSpec = rememberCalendarFadeSpec() - val selection by animateFloatAsState( + val selection = animateFloatAsState( targetValue = if (isSelected) 1f else 0f, animationSpec = if (reduceMotion) snap() else fadeSpec, label = "split-day-selection", ) + val outlineColor = MaterialTheme.colorScheme.primary + val density = LocalDensity.current + val outlineStroke = with(density) { SPLIT_SELECTION_STROKE.toPx() } + val outlineRadius = with(density) { CELL_CORNER.toPx() } // Background and content are separate layers, mirroring how the paged row is // built — and so the pill can be tagged for the morph on its own. Tagged as // one piece with its contents inside, the dots would be nested shared @@ -1331,11 +1343,22 @@ private fun SplitDayCell( Box( Modifier .fillMaxSize() - .border( - width = 1.5.dp, - color = MaterialTheme.colorScheme.primary.copy(alpha = selection), - shape = CELL_SHAPE, - ), + .drawBehind { + val alpha = selection.value + if (alpha <= 0f) return@drawBehind + // Inset by half the stroke: a stroke straddles the path it + // follows, so drawn on the bounds themselves the outer half + // would fall outside the cell and be clipped to a hairline + // along the edges. + val inset = outlineStroke / 2f + drawRoundRect( + color = outlineColor.copy(alpha = alpha), + topLeft = Offset(inset, inset), + size = Size(size.width - outlineStroke, size.height - outlineStroke), + cornerRadius = CornerRadius(outlineRadius - inset), + style = Stroke(width = outlineStroke), + ) + }, ) Column( modifier = Modifier.fillMaxSize().padding(top = 4.dp), diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/week/WeekScreen.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/week/WeekScreen.kt index 88b9a14..ea5243c 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/week/WeekScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/week/WeekScreen.kt @@ -5,7 +5,6 @@ import androidx.compose.animation.animateColorAsState import androidx.compose.animation.core.animateDpAsState import androidx.compose.foundation.background import androidx.compose.foundation.clickable -import androidx.compose.foundation.gestures.detectHorizontalDragGestures import androidx.compose.foundation.gestures.detectTapGestures import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.foundation.layout.Arrangement @@ -49,7 +48,6 @@ import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableFloatStateOf import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope @@ -94,6 +92,7 @@ import de.jeanlucmakiola.calendula.ui.common.rememberCurrentMinute import de.jeanlucmakiola.calendula.ui.common.ViewSwitcherPill import de.jeanlucmakiola.calendula.ui.common.calendarSlideTransition import de.jeanlucmakiola.calendula.ui.common.rememberCalendarFadeSpec +import de.jeanlucmakiola.calendula.ui.common.rememberCalendarPageSwipe import de.jeanlucmakiola.floret.identity.rememberReduceMotion import de.jeanlucmakiola.floret.locale.currentLocale import de.jeanlucmakiola.calendula.ui.common.LocalUse24HourFormat @@ -296,8 +295,6 @@ private fun WeekContent( modifier: Modifier = Modifier, ) { val density = LocalDensity.current - val threshold = with(density) { 24.dp.toPx() } - var dragAccum by remember { mutableFloatStateOf(0f) } val slideSpec = rememberCalendarSlideSpec() val fadeSpec = rememberCalendarFadeSpec() val reduceMotion = rememberReduceMotion() @@ -329,20 +326,7 @@ private fun WeekContent( // vertical scroll: a horizontal drag only crosses *this* detector's slop, // while a vertical drag is consumed by the inner scroll first — so the two // gestures coexist without fighting. - val swipeModifier = Modifier.pointerInput(Unit) { - detectHorizontalDragGestures( - onDragStart = { dragAccum = 0f }, - onDragEnd = { - when { - dragAccum < -threshold -> onSwipeNext() - dragAccum > threshold -> onSwipePrev() - } - dragAccum = 0f - }, - onDragCancel = { dragAccum = 0f }, - onHorizontalDrag = { _, drag -> dragAccum += drag }, - ) - } + val swipeModifier = rememberCalendarPageSwipe(onSwipeNext, onSwipePrev) AnimatedContent( targetState = state, From bdb16d069ee6f0aab2de4d953d68e2c8131b1aa2 Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Mon, 20 Jul 2026 20:34:56 +0200 Subject: [PATCH 62/78] fix(month): drag from the handle, and stop the outline hiding behind its cell (#53) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The handle advertised a gesture it didn't answer to — the drag lived on the grid alone, so the one part of the screen that says "pull me" was the one part that ignored being pulled. Grid and handle now drag as a single surface; the pane stays outside it, since it scrolls and is full of tappable rows. Tapping the handle still toggles: a parent drag and a child clickable coexist the same way they do in any scrollable list. The selection outline was still flickering through an expand or collapse, and being untagged was not enough to explain it. Tagged pieces paint into an overlay above the whole regular tree, so the outline — correctly left in that tree, to keep the collapsed cell's bounds — spent every transition hidden behind its own morphing pill and reappeared the moment it ended. It now renders into that same overlay while keeping its own bounds, which is what the untagged layout was for in the first place. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../calendula/ui/month/MonthMorph.kt | 23 +++++++ .../calendula/ui/month/MonthScreen.kt | 64 +++++++++++-------- 2 files changed, 61 insertions(+), 26 deletions(-) diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthMorph.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthMorph.kt index 1cd42c9..7dcf4c0 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthMorph.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthMorph.kt @@ -72,6 +72,29 @@ internal fun Modifier.morphElement(key: MonthMorphKey): Modifier { } } +/** + * Lift *untagged* content into the shared-transition overlay for the duration of + * a transition, so the travelling pieces don't bury it. + * + * Tagged content is drawn in the overlay, which is painted above the whole + * regular tree. Anything left in that tree therefore disappears behind it while a + * transition runs, however it was layered locally — which is what hid the split + * style's selection outline behind its own cell pill from the first frame of an + * expand to the last. + * + * The content keeps its own layout bounds; only where it is *painted* changes. So + * the outline still draws at the size the collapsed cell actually is, which is + * the whole reason it was left untagged. + */ +@OptIn(ExperimentalSharedTransitionApi::class) +@Composable +internal fun Modifier.morphOverlay(zIndex: Float = 1f): Modifier { + val morph = LocalMonthMorph.current ?: return this + return with(morph.shared) { + this@morphOverlay.renderInSharedTransitionScopeOverlay(zIndexInOverlay = zIndex) + } +} + /** * Tag content that means the same thing but *is drawn differently* on each side — * a 5dp dot and a titled bar — so only the bounds are shared and the contents diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthScreen.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthScreen.kt index c504759..5585973 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthScreen.kt @@ -1052,31 +1052,35 @@ private fun SplitMonthCollapsed( val reduceMotion = rememberReduceMotion() Column(Modifier.fillMaxSize()) { - AnimatedContent( - // The selection travels *with* the state so each page keeps its - // own. Read from outside, both pages would show the incoming - // one, and paging visibly threw the marker across the outgoing - // grid — onto the new month's 1st, which the old grid still - // shows among its trailing days — before the new page arrived. - targetState = state to selected, - modifier = swipeModifier, - // Keyed on the month alone, so a provider notification refreshing - // the month you are on — or a tap moving the selection within it - // — updates in place instead of sliding. - contentKey = { (s, _) -> s.month }, - transitionSpec = { - calendarSlideTransition(slideDir, slideSpec, fadeSpec, reduceMotion) - }, - label = "split-month-transition", - ) { (s, sel) -> - SplitMonthGrid( - state = s, - selected = sel, - showWeekNumbers = showWeekNumbers, - onSelectDay = onSelectDay, - ) + // Grid and handle drag as one surface — the handle is what advertises the + // gesture, so it has to answer to it as well as to a tap. The pane is + // outside: it scrolls, and is full of tappable rows. + Column(swipeModifier) { + AnimatedContent( + // The selection travels *with* the state so each page keeps its + // own. Read from outside, both pages would show the incoming + // one, and paging visibly threw the marker across the outgoing + // grid — onto the new month's 1st, which the old grid still + // shows among its trailing days — before the new page arrived. + targetState = state to selected, + // Keyed on the month alone, so a provider notification refreshing + // the month you are on — or a tap moving the selection within it + // — updates in place instead of sliding. + contentKey = { (s, _) -> s.month }, + transitionSpec = { + calendarSlideTransition(slideDir, slideSpec, fadeSpec, reduceMotion) + }, + label = "split-month-transition", + ) { (s, sel) -> + SplitMonthGrid( + state = s, + selected = sel, + showWeekNumbers = showWeekNumbers, + onSelectDay = onSelectDay, + ) + } + SplitExpandHandle(expanded = false, onToggle = onExpand) } - SplitExpandHandle(expanded = false, onToggle = onExpand) SplitDayPane( date = selected, today = state.today, @@ -1115,10 +1119,12 @@ private fun SplitMonthExpanded( val fadeSpec = rememberCalendarFadeSpec() val reduceMotion = rememberReduceMotion() - Column(Modifier.fillMaxSize()) { + // The whole screen drags here, handle included — there is no pane to keep out + // of it, and the handle is the obvious thing to reach for on the way back. + Column(Modifier.fillMaxSize().then(swipeModifier)) { AnimatedContent( targetState = state, - modifier = Modifier.weight(1f).then(swipeModifier), + modifier = Modifier.weight(1f), contentKey = { it.month }, transitionSpec = { calendarSlideTransition(slideDir, slideSpec, fadeSpec, reduceMotion) @@ -1340,9 +1346,15 @@ private fun SplitDayCell( // flashing over a grid that no longer had full-height cells. Untagged it // is laid out where the collapsed cell actually is and simply fades in, // which is the only size it is ever true at. + // + // Untagged is not enough on its own, though: the tagged pieces paint into + // an overlay above the whole regular tree, so the outline spent every + // transition hidden behind its own pill and reappeared when it ended. + // morphOverlay lifts it into that same overlay while keeping its bounds. Box( Modifier .fillMaxSize() + .morphOverlay() .drawBehind { val alpha = selection.value if (alpha <= 0f) return@drawBehind From 7fb01ab8d152343057cd216a00144b6818106525 Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Mon, 20 Jul 2026 20:43:21 +0200 Subject: [PATCH 63/78] fix(month): fade the selection outline with the page it belongs to (#53) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Sending the outline to the shared-transition overlay stopped it hiding behind its own cell, but it also lifted it out of the layer AnimatedContent fades the outgoing page with — so it stopped fading at all. It stood at full strength for the whole expand or collapse and then blinked out at the end, which is the opposite half of the same bug. The fade is reapplied on the lifted content's own layer, so it travels with it. That belongs in morphOverlay rather than at the call site: anything sent to the overlay loses the parent's fade the same way. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../calendula/ui/month/MonthMorph.kt | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthMorph.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthMorph.kt index 7dcf4c0..9a8e909 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthMorph.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthMorph.kt @@ -8,6 +8,7 @@ import androidx.compose.animation.fadeOut import androidx.compose.runtime.Composable import androidx.compose.runtime.compositionLocalOf import androidx.compose.ui.Modifier +import de.jeanlucmakiola.calendula.ui.common.rememberCalendarFadeSpec import kotlinx.datetime.LocalDate /** @@ -85,14 +86,30 @@ internal fun Modifier.morphElement(key: MonthMorphKey): Modifier { * The content keeps its own layout bounds; only where it is *painted* changes. So * the outline still draws at the size the collapsed cell actually is, which is * the whole reason it was left untagged. + * + * Lifting content out of the regular tree also lifts it out of the enter/exit + * layer [AnimatedContent][androidx.compose.animation.AnimatedContent] fades the + * outgoing page with, so it no longer fades at all — it stood at full strength + * for the whole transition and then blinked out at the end of it. The fade is + * therefore reapplied here, on the lifted content's own layer, so it travels with + * it. Anything sent to the overlay needs this, which is why it lives in the + * helper rather than at the call site. */ @OptIn(ExperimentalSharedTransitionApi::class) @Composable internal fun Modifier.morphOverlay(zIndex: Float = 1f): Modifier { val morph = LocalMonthMorph.current ?: return this - return with(morph.shared) { + val fadeSpec = rememberCalendarFadeSpec() + val lifted = with(morph.shared) { this@morphOverlay.renderInSharedTransitionScopeOverlay(zIndexInOverlay = zIndex) } + return with(morph.visibility) { + lifted.animateEnterExit( + enter = fadeIn(fadeSpec), + exit = fadeOut(fadeSpec), + label = "morph-overlay-fade", + ) + } } /** From ff6c4b6a4db948b0b7771727066cfbc340626d7f Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Mon, 20 Jul 2026 20:55:30 +0200 Subject: [PATCH 64/78] fix(month): let the event marks actually grow, and lift the "+N" out (#53) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two reasons a mark arrived a beat late instead of animating. The dots and bars carried morphBounds *inside* their own size modifiers, so the shared bounds had nothing to drive: the dot stayed pinned at 5dp however far its bar had travelled, and the bar sat at full width from the first frame, each snapping into place only once the transition ended. The growth was being clamped away. morphBounds now sits outside the width/height on both sides. A bar's offset stays outside it — that is where the bar sits, not how big it is. The "+N" overflow markers were untagged, so they spent every transition painted over by the overlay the tagged pieces draw into, exactly as the selection outline did. Both the compact grid's and the expanded grid's are now lifted into it. The outline itself moves to the fast effects spec, as asked: it is a small mark that only ever appears or disappears, and at the shared pace it lingered after the thing it marks had already moved. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../calendula/ui/month/MonthMorph.kt | 7 ++- .../calendula/ui/month/MonthScreen.kt | 44 ++++++++++++++----- 2 files changed, 39 insertions(+), 12 deletions(-) diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthMorph.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthMorph.kt index 9a8e909..523d30f 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthMorph.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthMorph.kt @@ -3,6 +3,7 @@ package de.jeanlucmakiola.calendula.ui.month import androidx.compose.animation.AnimatedVisibilityScope import androidx.compose.animation.ExperimentalSharedTransitionApi import androidx.compose.animation.SharedTransitionScope +import androidx.compose.animation.core.FiniteAnimationSpec import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut import androidx.compose.runtime.Composable @@ -97,9 +98,11 @@ internal fun Modifier.morphElement(key: MonthMorphKey): Modifier { */ @OptIn(ExperimentalSharedTransitionApi::class) @Composable -internal fun Modifier.morphOverlay(zIndex: Float = 1f): Modifier { +internal fun Modifier.morphOverlay( + zIndex: Float = 1f, + fadeSpec: FiniteAnimationSpec = rememberCalendarFadeSpec(), +): Modifier { val morph = LocalMonthMorph.current ?: return this - val fadeSpec = rememberCalendarFadeSpec() val lifted = with(morph.shared) { this@morphOverlay.renderInSharedTransitionScopeOverlay(zIndexInOverlay = zIndex) } diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthScreen.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthScreen.kt index 5585973..44aa97c 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthScreen.kt @@ -1313,10 +1313,13 @@ private fun SplitDayCell( // 42 cells at once, which is what made the outline shiver rather than fade. // Kept in the draw phase, the animation touches nothing but the pixels. val reduceMotion = rememberReduceMotion() - val fadeSpec = rememberCalendarFadeSpec() + // The outline runs on the *fast* effects spec, unlike everything else here. + // It is a small mark that only ever appears or disappears, and at the shared + // pace it lingered after the thing it marks had already moved. + val outlineSpec = MaterialTheme.motionScheme.fastEffectsSpec() val selection = animateFloatAsState( targetValue = if (isSelected) 1f else 0f, - animationSpec = if (reduceMotion) snap() else fadeSpec, + animationSpec = if (reduceMotion) snap() else outlineSpec, label = "split-day-selection", ) val outlineColor = MaterialTheme.colorScheme.primary @@ -1354,7 +1357,7 @@ private fun SplitDayCell( Box( Modifier .fillMaxSize() - .morphOverlay() + .morphOverlay(fadeSpec = outlineSpec) .drawBehind { val alpha = selection.value if (alpha <= 0f) return@drawBehind @@ -1408,18 +1411,29 @@ private fun SplitDots(date: LocalDate, events: List, hidden: Int, // The dot keeps its own circle and the bar its 4dp corners; they // cross-fade inside shared bounds, and at the dot's size the two // radii are a fraction of a pixel apart. + // + // morphBounds goes *outside* the size: the shared bounds have to be + // free to drive the measurement. Behind a fixed .size() the dot stayed + // 5dp for the whole transition however far its bar had travelled, and + // only snapped to full width once the transition ended — the growth + // never happened, it was clamped away. Box( modifier = Modifier - .size(SPLIT_DOT_SIZE) .morphBounds(MonthMorphKey.Event(date, event.instanceId)) + .size(SPLIT_DOT_SIZE) .background(eventFill(event.color, dark, soften), CircleShape), ) } if (hidden > 0) { + // Lifted into the overlay for the same reason the selection outline + // is: untagged content stays in the regular tree, which the tagged + // pieces paint over wholesale, so it vanished for the length of the + // transition and arrived a beat after everything else had settled. Text( text = "+$hidden", style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.morphOverlay(), ) } } @@ -1728,19 +1742,25 @@ private fun MonthWeekRow( x = colW * span.startCol, y = EVENT_ROW_HEIGHT * span.lane, ) - .width(colW * cols) - .height(EVENT_ROW_HEIGHT) - .padding(horizontal = CELL_GAP + 1.dp, vertical = 1.dp) // Anchored on the day the bar starts *in this row*, // which is where its dot sat. A bar carried in from // the previous week starts at column 0, and column // 0's dot is the one that grows into it. + // + // Outside the width/height, so the shared bounds + // drive the measurement instead of being pinned to + // the bar's final size from the first frame. The + // offset stays outside it: that is where the bar + // sits, not how big it is. .morphBounds( MonthMorphKey.Event( date = week.days[span.startCol], instanceId = span.event.instanceId, ), - ), + ) + .width(colW * cols) + .height(EVENT_ROW_HEIGHT) + .padding(horizontal = CELL_GAP + 1.dp, vertical = 1.dp), ) } // Single-day timed pills + overflow, per column. Pills fill the @@ -1766,10 +1786,10 @@ private fun MonthWeekRow( x = colW * col, y = EVENT_ROW_HEIGHT * freeSlots[i], ) + .morphBounds(MonthMorphKey.Event(d, ev.instanceId)) .width(colW) .height(EVENT_ROW_HEIGHT) - .padding(horizontal = CELL_GAP + 1.dp, vertical = 1.dp) - .morphBounds(MonthMorphKey.Event(d, ev.instanceId)), + .padding(horizontal = CELL_GAP + 1.dp, vertical = 1.dp), ) } val hidden = (week.countByDay[d] ?: 0) - occupied.size - pillsShown.size @@ -1786,6 +1806,10 @@ private fun MonthWeekRow( dark = dark, modifier = Modifier .offset(x = colW * col, y = EVENT_ROW_HEIGHT * MAX_EVENT_ROWS) + // Untagged, so lifted into the overlay or it + // spends the transition painted over — see the + // compact grid's "+N". + .morphOverlay() .width(colW) .padding(horizontal = 3.dp), ) From 279872aa3be42f46beff1f4505f0fc6a5de92a6c Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Mon, 20 Jul 2026 21:09:25 +0200 Subject: [PATCH 65/78] fix(month): morph in place instead of over the top of the grid (#53) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Travelling pieces were painted into an overlay above the entire tree — Compose's default, and right for a thumbnail flying across a screen, wrong for a grid changing shape in place. Everything tagged left the tree's z-order, so every untagged neighbour spent the transition buried under it. Lifting each of those out in turn — the selection outline, then both "+N" markers — fixed the burying and bought something worse: they floated over the grid on a layer of their own, out of step with what they belong to. Three pieces needing the same escape hatch was the tell that the overlay itself was wrong here. Nothing renders in the overlay now. One z-order, one clip, so the grid reads as a single surface changing shape rather than a stack of pieces sliding past each other. The outline goes back to being plain content that sits above its own pill the ordinary way, and morphOverlay is gone. The "+N" markers become a matched pair instead of an orphan. The compact grid writes the count as text and the expanded one as a row of dots, but they stand for the same events on exactly the same days — both seat three and overflow the rest — so they were always two halves of one marker. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../calendula/ui/month/MonthMorph.kt | 71 ++++++++----------- .../calendula/ui/month/MonthScreen.kt | 22 +++--- 2 files changed, 36 insertions(+), 57 deletions(-) diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthMorph.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthMorph.kt index 523d30f..8c61e9f 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthMorph.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthMorph.kt @@ -3,13 +3,11 @@ package de.jeanlucmakiola.calendula.ui.month import androidx.compose.animation.AnimatedVisibilityScope import androidx.compose.animation.ExperimentalSharedTransitionApi import androidx.compose.animation.SharedTransitionScope -import androidx.compose.animation.core.FiniteAnimationSpec import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut import androidx.compose.runtime.Composable import androidx.compose.runtime.compositionLocalOf import androidx.compose.ui.Modifier -import de.jeanlucmakiola.calendula.ui.common.rememberCalendarFadeSpec import kotlinx.datetime.LocalDate /** @@ -46,6 +44,16 @@ internal sealed interface MonthMorphKey { */ data class Event(val date: LocalDate, val instanceId: Long) : MonthMorphKey + /** + * A day's "+N" marker. The compact grid writes it as a count beside the dots + * and the expanded one as a row of small dots under the bars, but they stand + * for the same events and appear on exactly the same days — both sides seat + * three and overflow the rest — so they are a matched pair, not two unrelated + * bits of text. Tagged, the marker travels with its day like everything else + * in the cell. + */ + data class Overflow(val date: LocalDate) : MonthMorphKey + /** The grab handle, which slides from the pane's seam to the foot of the grid. */ data object Handle : MonthMorphKey } @@ -61,6 +69,22 @@ internal val LocalMonthMorph = compositionLocalOf { null } /** * Tag content that is *the same thing* on both sides — a background pill, a day * number — so it animates between its two positions and sizes. + * + * ### Why nothing renders in the overlay + * + * By default a travelling piece is painted into an overlay above the *entire* + * regular tree, so it can fly over anything in its way. That is right for a + * thumbnail crossing a screen, and wrong here: everything is moving inside one + * grid, and the overlay meant every untagged neighbour — the selection outline, + * the "+N" markers — spent the transition buried under pieces that had left the + * tree's z-order behind. Lifting each of them out in turn fixed the burying and + * bought a worse problem: they then floated over the grid on their own layer, + * out of step with it. + * + * Rendering in place puts everything back in one z-order and one clip, which is + * what makes the grid read as a single surface changing shape rather than a + * stack of pieces sliding past each other. Nothing here needs to escape its + * ancestors: the cells, marks and markers all travel within the grid. */ @OptIn(ExperimentalSharedTransitionApi::class) @Composable @@ -70,47 +94,7 @@ internal fun Modifier.morphElement(key: MonthMorphKey): Modifier { this@morphElement.sharedElement( sharedContentState = rememberSharedContentState(key), animatedVisibilityScope = morph.visibility, - ) - } -} - -/** - * Lift *untagged* content into the shared-transition overlay for the duration of - * a transition, so the travelling pieces don't bury it. - * - * Tagged content is drawn in the overlay, which is painted above the whole - * regular tree. Anything left in that tree therefore disappears behind it while a - * transition runs, however it was layered locally — which is what hid the split - * style's selection outline behind its own cell pill from the first frame of an - * expand to the last. - * - * The content keeps its own layout bounds; only where it is *painted* changes. So - * the outline still draws at the size the collapsed cell actually is, which is - * the whole reason it was left untagged. - * - * Lifting content out of the regular tree also lifts it out of the enter/exit - * layer [AnimatedContent][androidx.compose.animation.AnimatedContent] fades the - * outgoing page with, so it no longer fades at all — it stood at full strength - * for the whole transition and then blinked out at the end of it. The fade is - * therefore reapplied here, on the lifted content's own layer, so it travels with - * it. Anything sent to the overlay needs this, which is why it lives in the - * helper rather than at the call site. - */ -@OptIn(ExperimentalSharedTransitionApi::class) -@Composable -internal fun Modifier.morphOverlay( - zIndex: Float = 1f, - fadeSpec: FiniteAnimationSpec = rememberCalendarFadeSpec(), -): Modifier { - val morph = LocalMonthMorph.current ?: return this - val lifted = with(morph.shared) { - this@morphOverlay.renderInSharedTransitionScopeOverlay(zIndexInOverlay = zIndex) - } - return with(morph.visibility) { - lifted.animateEnterExit( - enter = fadeIn(fadeSpec), - exit = fadeOut(fadeSpec), - label = "morph-overlay-fade", + renderInOverlayDuringTransition = false, ) } } @@ -136,6 +120,7 @@ internal fun Modifier.morphBounds(key: MonthMorphKey): Modifier { enter = fadeIn(), exit = fadeOut(), resizeMode = SharedTransitionScope.ResizeMode.RemeasureToBounds, + renderInOverlayDuringTransition = false, ) } } diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthScreen.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthScreen.kt index 44aa97c..21fab47 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthScreen.kt @@ -1350,14 +1350,12 @@ private fun SplitDayCell( // is laid out where the collapsed cell actually is and simply fades in, // which is the only size it is ever true at. // - // Untagged is not enough on its own, though: the tagged pieces paint into - // an overlay above the whole regular tree, so the outline spent every - // transition hidden behind its own pill and reappeared when it ended. - // morphOverlay lifts it into that same overlay while keeping its bounds. + // It can stay plain content now that nothing renders into an overlay above + // the tree — see [morphElement]. It sits above its own pill by ordinary + // z-order and fades with the page it belongs to, in step with the rest. Box( Modifier .fillMaxSize() - .morphOverlay(fadeSpec = outlineSpec) .drawBehind { val alpha = selection.value if (alpha <= 0f) return@drawBehind @@ -1425,15 +1423,14 @@ private fun SplitDots(date: LocalDate, events: List, hidden: Int, ) } if (hidden > 0) { - // Lifted into the overlay for the same reason the selection outline - // is: untagged content stays in the regular tree, which the tagged - // pieces paint over wholesale, so it vanished for the length of the - // transition and arrived a beat after everything else had settled. + // Tagged, not lifted: this count and the expanded grid's dot row are + // the same marker on the same day, so it travels with its cell like + // everything else rather than riding above the grid on its own layer. Text( text = "+$hidden", style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.morphOverlay(), + modifier = Modifier.morphBounds(MonthMorphKey.Overflow(date)), ) } } @@ -1806,10 +1803,7 @@ private fun MonthWeekRow( dark = dark, modifier = Modifier .offset(x = colW * col, y = EVENT_ROW_HEIGHT * MAX_EVENT_ROWS) - // Untagged, so lifted into the overlay or it - // spends the transition painted over — see the - // compact grid's "+N". - .morphOverlay() + .morphBounds(MonthMorphKey.Overflow(d)) .width(colW) .padding(horizontal = 3.dp), ) From 1e9581528e3b8be59b8f273f3d6ee9ce4893afbd Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Mon, 20 Jul 2026 21:40:46 +0200 Subject: [PATCH 66/78] fix(month): stop the row clip swallowing marks in mid-flight (#53) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Frame-by-frame, the marks were not fading in late — they were invisible for the first half of their journey and then appearing already near the destination, row by row from the top down. A mark's two homes are in different rows: the dot for the 20th sits a third of the way down the compact grid, its bar most of the way down the expanded one. The events box clips to its own row, so a mark arriving there is out of bounds — and undrawn — until it crosses in. The lower the row, the further it travels and the later it showed, which is exactly the order the capture shows them appearing in. Rendering in place rather than in an overlay is what subjects marks to that clip, and that is worth keeping — it is what puts the whole grid in one z-order. So the clip yields instead, and only while pieces are in flight; at rest it still stops a bar spilling into the row below. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../calendula/ui/month/MonthMorph.kt | 19 +++++++++++++++++++ .../calendula/ui/month/MonthScreen.kt | 7 ++++++- 2 files changed, 25 insertions(+), 1 deletion(-) diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthMorph.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthMorph.kt index 8c61e9f..c8d099c 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthMorph.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthMorph.kt @@ -66,6 +66,25 @@ internal class MonthMorphScope( internal val LocalMonthMorph = compositionLocalOf { null } +/** + * True while the grid is mid-morph, for content that has to stop clipping to let + * the travelling pieces through. + * + * A mark's two homes are in *different rows*: the dot for the 20th sits a third + * of the way down the compact grid, its bar most of the way down the expanded + * one. Clipped to the row it is arriving at, a mark spends the first half of its + * journey outside those bounds and is simply not drawn — so it appears from + * nowhere, part-way through, already near its destination. Rendering in place + * rather than in an overlay is what subjects them to that clip, and is worth + * keeping; the clip is what has to yield, and only while pieces are in flight. + */ +@OptIn(ExperimentalSharedTransitionApi::class) +@Composable +internal fun morphInFlight(): Boolean { + val morph = LocalMonthMorph.current ?: return false + return morph.shared.isTransitionActive +} + /** * Tag content that is *the same thing* on both sides — a background pill, a day * number — so it animates between its two positions and sizes. diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthScreen.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthScreen.kt index 21fab47..faa39ce 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthScreen.kt @@ -1647,6 +1647,7 @@ private fun MonthWeekRow( val dark = isSystemInDarkTheme() val laneCount = (week.spans.maxOfOrNull { it.lane } ?: -1) + 1 val shownLanes = laneCount.coerceAtMost(MAX_EVENT_ROWS) + val morphing = morphInFlight() Row(modifier) { // Optional calendar-week gutter, sized so the seven day columns below @@ -1720,11 +1721,15 @@ private fun MonthWeekRow( // Breathing room between the day number (and today's circle) and the // first event row. Spacer(Modifier.height(DAY_NUMBER_GAP)) + // Clipped at rest so a bar can't spill into the row below, but not + // while a morph is in flight: a mark travels between two different + // rows, and clipping it to the one it is arriving at hides it for + // the whole first half of the journey. See [morphInFlight]. Box( modifier = Modifier .fillMaxWidth() .weight(1f) - .clipToBounds(), + .then(if (morphing) Modifier else Modifier.clipToBounds()), ) { // Spanning bars on their shared lanes. week.spans.filter { it.lane < shownLanes }.forEach { span -> From bf38dac5d7226494259d77090ada0f7e9c5a2741 Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Mon, 20 Jul 2026 21:51:45 +0200 Subject: [PATCH 67/78] fix(month): only tag the dot that has a bar to become (#53) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Dots on the middle of a multi-day run flew in from the top of the grid instead of fading where they stand. They were meant to be left unmatched — a bar is drawn once per row, from the column it starts in, so only that day's dot has a counterpart. But "unmatched" was implemented as tagged-and-unmatched, which is a different thing: a shared element entering with no partner has no bounds to start from, so it animates in from the layout origin. The top. Seating now says which day a bar is actually drawn from, and only that dot carries a tag. The rest are plain content and fade in place, which is what the comment claimed they did all along. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../calendula/ui/month/MonthScreen.kt | 23 +++++++-- .../calendula/ui/month/MonthUiState.kt | 24 +++++++-- .../calendula/ui/month/LaneEventsTest.kt | 49 ++++++++++++++++--- 3 files changed, 81 insertions(+), 15 deletions(-) diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthScreen.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthScreen.kt index faa39ce..6155f20 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthScreen.kt @@ -1287,7 +1287,7 @@ internal fun SplitMonthGrid( @Composable private fun SplitDayCell( date: LocalDate, - events: List, + events: List, /** Events on this day that didn't fit a lane, shown as a "+N" beside the dots. */ hidden: Int, isToday: Boolean, @@ -1398,14 +1398,14 @@ private fun SplitDayCell( * bar with no dot to grow out of. */ @Composable -private fun SplitDots(date: LocalDate, events: List, hidden: Int, dark: Boolean) { +private fun SplitDots(date: LocalDate, events: List, hidden: Int, dark: Boolean) { if (events.isEmpty()) return val soften = LocalSoftenColors.current Row( horizontalArrangement = Arrangement.spacedBy(2.dp), verticalAlignment = Alignment.CenterVertically, ) { - events.forEach { event -> + events.forEach { seated -> // The dot keeps its own circle and the bar its 4dp corners; they // cross-fade inside shared bounds, and at the dot's size the two // radii are a fraction of a pixel apart. @@ -1415,11 +1415,24 @@ private fun SplitDots(date: LocalDate, events: List, hidden: Int, // 5dp for the whole transition however far its bar had travelled, and // only snapped to full width once the transition ended — the growth // never happened, it was clamped away. + // + // Only tagged where a bar actually meets it. A dot on the middle of a + // multi-day run has nothing to travel to, and tagging it regardless + // gave it no bounds to start from, so it flew in from the top of the + // grid rather than fading where it stands. Box( modifier = Modifier - .morphBounds(MonthMorphKey.Event(date, event.instanceId)) + .then( + if (seated.anchored) { + Modifier.morphBounds( + MonthMorphKey.Event(date, seated.event.instanceId), + ) + } else { + Modifier + }, + ) .size(SPLIT_DOT_SIZE) - .background(eventFill(event.color, dark, soften), CircleShape), + .background(eventFill(seated.event.color, dark, soften), CircleShape), ) } if (hidden > 0) { diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthUiState.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthUiState.kt index 3b1bc80..34441c4 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthUiState.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthUiState.kt @@ -39,6 +39,19 @@ data class MonthWeek( val countByDay: Map, ) +/** + * One event seated in a lane of one day, and whether *this* day is where the + * expanded grid draws it. + * + * A multi-day event has a dot on every day it covers but only one bar, drawn from + * where it starts in that week. So only the start day's dot has something to + * become; the rest are [anchored] = false and must not be tagged for the morph at + * all. Tagging them anyway gives Compose a shared element that enters with no + * partner and therefore no bounds to start from, and it flies in from the layout + * origin — the top of the grid — instead of quietly fading where it stands (#53). + */ +data class SeatedEvent(val event: EventInstance, val anchored: Boolean) + /** * The events occupying each lane of [day] — column [col] of this week — in lane * order, capped at [laneCap] lanes. @@ -53,16 +66,19 @@ data class MonthWeek( * left dot _i_ standing for no particular event, and quietly merged two events * that shared a calendar into a single dot. */ -fun MonthWeek.laneEvents(col: Int, day: LocalDate, laneCap: Int): List { - val byLane = arrayOfNulls(laneCap) +fun MonthWeek.laneEvents(col: Int, day: LocalDate, laneCap: Int): List { + val byLane = arrayOfNulls(laneCap) spans.forEach { span -> if (span.lane < laneCap && col in span.startCol..span.endCol) { - byLane[span.lane] = span.event + // A bar is drawn once per row, from the column it starts in — so that + // is the only day of it whose dot has a counterpart to travel to. + byLane[span.lane] = SeatedEvent(span.event, anchored = col == span.startCol) } } val free = (0 until laneCap).filter { byLane[it] == null } + // A single-day event is drawn on its own day, so it is always anchored there. timedByDay[day].orEmpty().take(free.size).forEachIndexed { i, event -> - byLane[free[i]] = event + byLane[free[i]] = SeatedEvent(event, anchored = true) } return byLane.filterNotNull() } diff --git a/app/src/test/java/de/jeanlucmakiola/calendula/ui/month/LaneEventsTest.kt b/app/src/test/java/de/jeanlucmakiola/calendula/ui/month/LaneEventsTest.kt index 0374ab3..572da8f 100644 --- a/app/src/test/java/de/jeanlucmakiola/calendula/ui/month/LaneEventsTest.kt +++ b/app/src/test/java/de/jeanlucmakiola/calendula/ui/month/LaneEventsTest.kt @@ -58,7 +58,7 @@ class LaneEventsTest { val week = rowOfJuly6(listOf(bar, meeting)) // Jul 7 is column 1 of a Monday-anchored row starting Jul 6. - assertThat(week.laneEvents(col = 1, day = LocalDate(2026, 7, 7), laneCap = 3)) + assertThat(week.laneEvents(col = 1, day = LocalDate(2026, 7, 7), laneCap = 3).map { it.event }) .containsExactly(bar, meeting) .inOrder() } @@ -69,7 +69,7 @@ class LaneEventsTest { val monday = timed(LocalDate(2026, 7, 6), hour = 9, id = 2L) val week = rowOfJuly6(listOf(bar, monday)) - assertThat(week.laneEvents(col = 0, day = LocalDate(2026, 7, 6), laneCap = 3)) + assertThat(week.laneEvents(col = 0, day = LocalDate(2026, 7, 6), laneCap = 3).map { it.event }) .containsExactly(monday) } @@ -80,11 +80,48 @@ class LaneEventsTest { (1..3).forEach { col -> val day = LocalDate(2026, 7, 6 + col) - assertThat(week.laneEvents(col, day, laneCap = 3)).containsExactly(bar) + assertThat(week.laneEvents(col, day, laneCap = 3).map { it.event }).containsExactly(bar) } assertThat(week.laneEvents(col = 4, day = LocalDate(2026, 7, 10), laneCap = 3)).isEmpty() } + /** + * Only the day a bar is drawn from has a counterpart to morph into. Tagging + * the other days' dots too gave them a shared element with no partner and so + * no bounds to start from, and they flew in from the top of the grid. + */ + @Test + fun `only the day a multi-day bar starts on is anchored`() { + val bar = allDay(LocalDate(2026, 7, 7), LocalDate(2026, 7, 9), id = 1L) + val week = rowOfJuly6(listOf(bar)) + + val anchored = (1..3).map { col -> + week.laneEvents(col, LocalDate(2026, 7, 6 + col), laneCap = 3).single().anchored + } + assertThat(anchored).containsExactly(true, false, false).inOrder() + } + + /** A bar carried in from the previous week restarts at column 0 of this row. */ + @Test + fun `a bar continuing into the row anchors on its first day here`() { + val bar = allDay(LocalDate(2026, 7, 3), LocalDate(2026, 7, 8), id = 1L) + val week = rowOfJuly6(listOf(bar)) + + assertThat(week.laneEvents(col = 0, day = LocalDate(2026, 7, 6), laneCap = 3).single().anchored) + .isTrue() + assertThat(week.laneEvents(col = 1, day = LocalDate(2026, 7, 7), laneCap = 3).single().anchored) + .isFalse() + } + + @Test + fun `a single-day event is always anchored on its own day`() { + val meeting = timed(LocalDate(2026, 7, 6), hour = 9, id = 1L) + val week = rowOfJuly6(listOf(meeting)) + + assertThat(week.laneEvents(col = 0, day = LocalDate(2026, 7, 6), laneCap = 3).single().anchored) + .isTrue() + } + /** The bug the colour-gathered dots had: one dot for two events on one calendar. */ @Test fun `events sharing a colour each keep their own lane`() { @@ -92,7 +129,7 @@ class LaneEventsTest { val second = timed(LocalDate(2026, 7, 6), hour = 14, id = 2L, color = RED) val week = rowOfJuly6(listOf(first, second)) - assertThat(week.laneEvents(col = 0, day = LocalDate(2026, 7, 6), laneCap = 3)) + assertThat(week.laneEvents(col = 0, day = LocalDate(2026, 7, 6), laneCap = 3).map { it.event }) .containsExactly(first, second) .inOrder() } @@ -104,7 +141,7 @@ class LaneEventsTest { val seated = week.laneEvents(col = 0, day = LocalDate(2026, 7, 6), laneCap = 3) assertThat(seated).hasSize(3) - assertThat(seated).containsExactlyElementsIn(events.take(3)).inOrder() + assertThat(seated.map { it.event }).containsExactlyElementsIn(events.take(3)).inOrder() assertThat(week.countByDay[LocalDate(2026, 7, 6)]!! - seated.size).isEqualTo(2) } @@ -119,7 +156,7 @@ class LaneEventsTest { val seated = week.laneEvents(col = 0, day = LocalDate(2026, 7, 6), laneCap = 3) assertThat(seated).hasSize(3) assertThat(week.spans.filter { it.lane >= 3 }.map { it.event }) - .containsNoneIn(seated) + .containsNoneIn(seated.map { it.event }) } private companion object { From 7a05fc3ac1dfeb07dd7ca1ab2dae45ed8dc1fa94 Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Mon, 20 Jul 2026 22:06:22 +0200 Subject: [PATCH 68/78] fix(month): give every covered day's dot a place inside the bar (#53) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A multi-day event has a dot on every day it covers but only one bar, so the dots away from its start had no counterpart. Untagged they stood still and faded; tagged without a partner they flew in from the top of the grid. Neither is an animation — the last commit swapped one for the other. The expanded grid now places an invisible slice of the bar in each further column it spans, keyed to that column's day. Every dot has a real place to come out of and go back into, at its own column, so it drops out of the bar above it instead of appearing from nowhere. That makes the seating's anchored flag pointless again — every dot has a partner now — so it and its tests come back out. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../calendula/ui/month/MonthScreen.kt | 50 ++++++++++++------- .../calendula/ui/month/MonthUiState.kt | 24 ++------- .../calendula/ui/month/LaneEventsTest.kt | 49 +++--------------- 3 files changed, 43 insertions(+), 80 deletions(-) diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthScreen.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthScreen.kt index 6155f20..2675e6e 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthScreen.kt @@ -1287,7 +1287,7 @@ internal fun SplitMonthGrid( @Composable private fun SplitDayCell( date: LocalDate, - events: List, + events: List, /** Events on this day that didn't fit a lane, shown as a "+N" beside the dots. */ hidden: Int, isToday: Boolean, @@ -1398,14 +1398,14 @@ private fun SplitDayCell( * bar with no dot to grow out of. */ @Composable -private fun SplitDots(date: LocalDate, events: List, hidden: Int, dark: Boolean) { +private fun SplitDots(date: LocalDate, events: List, hidden: Int, dark: Boolean) { if (events.isEmpty()) return val soften = LocalSoftenColors.current Row( horizontalArrangement = Arrangement.spacedBy(2.dp), verticalAlignment = Alignment.CenterVertically, ) { - events.forEach { seated -> + events.forEach { event -> // The dot keeps its own circle and the bar its 4dp corners; they // cross-fade inside shared bounds, and at the dot's size the two // radii are a fraction of a pixel apart. @@ -1416,23 +1416,14 @@ private fun SplitDots(date: LocalDate, events: List, hidden: Int, d // only snapped to full width once the transition ended — the growth // never happened, it was clamped away. // - // Only tagged where a bar actually meets it. A dot on the middle of a - // multi-day run has nothing to travel to, and tagging it regardless - // gave it no bounds to start from, so it flew in from the top of the - // grid rather than fading where it stands. + // *Every* dot is tagged, including those in the middle of a multi-day + // run: the expanded grid gives each covered column its own anchor + // inside the bar, so each one has a real place to come from. Box( modifier = Modifier - .then( - if (seated.anchored) { - Modifier.morphBounds( - MonthMorphKey.Event(date, seated.event.instanceId), - ) - } else { - Modifier - }, - ) + .morphBounds(MonthMorphKey.Event(date, event.instanceId)) .size(SPLIT_DOT_SIZE) - .background(eventFill(seated.event.color, dark, soften), CircleShape), + .background(eventFill(event.color, dark, soften), CircleShape), ) } if (hidden > 0) { @@ -1777,6 +1768,31 @@ private fun MonthWeekRow( .height(EVENT_ROW_HEIGHT) .padding(horizontal = CELL_GAP + 1.dp, vertical = 1.dp), ) + // One invisible slice of the bar per further column it + // covers. A multi-day event has a dot on every day but + // only one bar, so those other dots had nothing to travel + // to and sat still while the rest of the grid moved. + // + // These give each of them a real place inside the bar to + // come out of and go back into, at its own column — so a + // dot drops out of the bar above it rather than appearing + // from nowhere, or from the top of the grid, which is what + // an untagged dot and a partnerless tag respectively did. + for (c in (span.startCol + 1)..span.endCol) { + Box( + Modifier + .offset(x = colW * c, y = EVENT_ROW_HEIGHT * span.lane) + .morphBounds( + MonthMorphKey.Event( + date = week.days[c], + instanceId = span.event.instanceId, + ), + ) + .width(colW) + .height(EVENT_ROW_HEIGHT) + .padding(horizontal = CELL_GAP + 1.dp, vertical = 1.dp), + ) + } } // Single-day timed pills + overflow, per column. Pills fill the // lane slots no bar occupies on THIS day (top-most first), so a diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthUiState.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthUiState.kt index 34441c4..3b1bc80 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthUiState.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthUiState.kt @@ -39,19 +39,6 @@ data class MonthWeek( val countByDay: Map, ) -/** - * One event seated in a lane of one day, and whether *this* day is where the - * expanded grid draws it. - * - * A multi-day event has a dot on every day it covers but only one bar, drawn from - * where it starts in that week. So only the start day's dot has something to - * become; the rest are [anchored] = false and must not be tagged for the morph at - * all. Tagging them anyway gives Compose a shared element that enters with no - * partner and therefore no bounds to start from, and it flies in from the layout - * origin — the top of the grid — instead of quietly fading where it stands (#53). - */ -data class SeatedEvent(val event: EventInstance, val anchored: Boolean) - /** * The events occupying each lane of [day] — column [col] of this week — in lane * order, capped at [laneCap] lanes. @@ -66,19 +53,16 @@ data class SeatedEvent(val event: EventInstance, val anchored: Boolean) * left dot _i_ standing for no particular event, and quietly merged two events * that shared a calendar into a single dot. */ -fun MonthWeek.laneEvents(col: Int, day: LocalDate, laneCap: Int): List { - val byLane = arrayOfNulls(laneCap) +fun MonthWeek.laneEvents(col: Int, day: LocalDate, laneCap: Int): List { + val byLane = arrayOfNulls(laneCap) spans.forEach { span -> if (span.lane < laneCap && col in span.startCol..span.endCol) { - // A bar is drawn once per row, from the column it starts in — so that - // is the only day of it whose dot has a counterpart to travel to. - byLane[span.lane] = SeatedEvent(span.event, anchored = col == span.startCol) + byLane[span.lane] = span.event } } val free = (0 until laneCap).filter { byLane[it] == null } - // A single-day event is drawn on its own day, so it is always anchored there. timedByDay[day].orEmpty().take(free.size).forEachIndexed { i, event -> - byLane[free[i]] = SeatedEvent(event, anchored = true) + byLane[free[i]] = event } return byLane.filterNotNull() } diff --git a/app/src/test/java/de/jeanlucmakiola/calendula/ui/month/LaneEventsTest.kt b/app/src/test/java/de/jeanlucmakiola/calendula/ui/month/LaneEventsTest.kt index 572da8f..0374ab3 100644 --- a/app/src/test/java/de/jeanlucmakiola/calendula/ui/month/LaneEventsTest.kt +++ b/app/src/test/java/de/jeanlucmakiola/calendula/ui/month/LaneEventsTest.kt @@ -58,7 +58,7 @@ class LaneEventsTest { val week = rowOfJuly6(listOf(bar, meeting)) // Jul 7 is column 1 of a Monday-anchored row starting Jul 6. - assertThat(week.laneEvents(col = 1, day = LocalDate(2026, 7, 7), laneCap = 3).map { it.event }) + assertThat(week.laneEvents(col = 1, day = LocalDate(2026, 7, 7), laneCap = 3)) .containsExactly(bar, meeting) .inOrder() } @@ -69,7 +69,7 @@ class LaneEventsTest { val monday = timed(LocalDate(2026, 7, 6), hour = 9, id = 2L) val week = rowOfJuly6(listOf(bar, monday)) - assertThat(week.laneEvents(col = 0, day = LocalDate(2026, 7, 6), laneCap = 3).map { it.event }) + assertThat(week.laneEvents(col = 0, day = LocalDate(2026, 7, 6), laneCap = 3)) .containsExactly(monday) } @@ -80,48 +80,11 @@ class LaneEventsTest { (1..3).forEach { col -> val day = LocalDate(2026, 7, 6 + col) - assertThat(week.laneEvents(col, day, laneCap = 3).map { it.event }).containsExactly(bar) + assertThat(week.laneEvents(col, day, laneCap = 3)).containsExactly(bar) } assertThat(week.laneEvents(col = 4, day = LocalDate(2026, 7, 10), laneCap = 3)).isEmpty() } - /** - * Only the day a bar is drawn from has a counterpart to morph into. Tagging - * the other days' dots too gave them a shared element with no partner and so - * no bounds to start from, and they flew in from the top of the grid. - */ - @Test - fun `only the day a multi-day bar starts on is anchored`() { - val bar = allDay(LocalDate(2026, 7, 7), LocalDate(2026, 7, 9), id = 1L) - val week = rowOfJuly6(listOf(bar)) - - val anchored = (1..3).map { col -> - week.laneEvents(col, LocalDate(2026, 7, 6 + col), laneCap = 3).single().anchored - } - assertThat(anchored).containsExactly(true, false, false).inOrder() - } - - /** A bar carried in from the previous week restarts at column 0 of this row. */ - @Test - fun `a bar continuing into the row anchors on its first day here`() { - val bar = allDay(LocalDate(2026, 7, 3), LocalDate(2026, 7, 8), id = 1L) - val week = rowOfJuly6(listOf(bar)) - - assertThat(week.laneEvents(col = 0, day = LocalDate(2026, 7, 6), laneCap = 3).single().anchored) - .isTrue() - assertThat(week.laneEvents(col = 1, day = LocalDate(2026, 7, 7), laneCap = 3).single().anchored) - .isFalse() - } - - @Test - fun `a single-day event is always anchored on its own day`() { - val meeting = timed(LocalDate(2026, 7, 6), hour = 9, id = 1L) - val week = rowOfJuly6(listOf(meeting)) - - assertThat(week.laneEvents(col = 0, day = LocalDate(2026, 7, 6), laneCap = 3).single().anchored) - .isTrue() - } - /** The bug the colour-gathered dots had: one dot for two events on one calendar. */ @Test fun `events sharing a colour each keep their own lane`() { @@ -129,7 +92,7 @@ class LaneEventsTest { val second = timed(LocalDate(2026, 7, 6), hour = 14, id = 2L, color = RED) val week = rowOfJuly6(listOf(first, second)) - assertThat(week.laneEvents(col = 0, day = LocalDate(2026, 7, 6), laneCap = 3).map { it.event }) + assertThat(week.laneEvents(col = 0, day = LocalDate(2026, 7, 6), laneCap = 3)) .containsExactly(first, second) .inOrder() } @@ -141,7 +104,7 @@ class LaneEventsTest { val seated = week.laneEvents(col = 0, day = LocalDate(2026, 7, 6), laneCap = 3) assertThat(seated).hasSize(3) - assertThat(seated.map { it.event }).containsExactlyElementsIn(events.take(3)).inOrder() + assertThat(seated).containsExactlyElementsIn(events.take(3)).inOrder() assertThat(week.countByDay[LocalDate(2026, 7, 6)]!! - seated.size).isEqualTo(2) } @@ -156,7 +119,7 @@ class LaneEventsTest { val seated = week.laneEvents(col = 0, day = LocalDate(2026, 7, 6), laneCap = 3) assertThat(seated).hasSize(3) assertThat(week.spans.filter { it.lane >= 3 }.map { it.event }) - .containsNoneIn(seated.map { it.event }) + .containsNoneIn(seated) } private companion object { From fb90c94e37224e0c6f844b80144358c80491623a Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Mon, 20 Jul 2026 22:19:19 +0200 Subject: [PATCH 69/78] fix(month): let the selection outline travel with its cell (#53) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The outline was the last piece with nothing to morph against, so it could only fade in place while everything around it moved. The expanded grid draws no outline by design — once the cells carry real event bars it is one mark too many — which left it partnerless. Same answer as the multi-day dots: give it an invisible stand-in over the same cell in the expanded grid. It draws nothing and exists only to be the other end of the morph, so the outline now rides its cell between the two layouts. Tagged only while it is the selected day. Tagged unconditionally it would put a key on all 42 cells against the single partner on offer, and the other 41 would fly in from the layout origin — the failure the dots just had. The stand-in carries the same padding as the outline's own bounds, or it would arrive at the wrong size, and it sits in its own layer rather than inside the cell pill, which is itself tagged and would make it travel twice. MonthGrid takes `selected` again, but for an anchor rather than a mark; the other styles pass nothing and compose no extra layer at all. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../calendula/ui/month/MonthMorph.kt | 9 +++ .../calendula/ui/month/MonthScreen.kt | 58 +++++++++++++++++-- 2 files changed, 61 insertions(+), 6 deletions(-) diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthMorph.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthMorph.kt index c8d099c..e46e36b 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthMorph.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthMorph.kt @@ -54,6 +54,15 @@ internal sealed interface MonthMorphKey { */ data class Overflow(val date: LocalDate) : MonthMorphKey + /** + * The selected day's outline. The expanded grid draws no outline — once the + * cells carry real event bars it is one mark too many — so this pairs the + * compact grid's outline with an *invisible* stand-in over the same cell + * there. Without one it had nowhere to travel and could only fade in place + * while everything around it moved. + */ + data class Selection(val date: LocalDate) : MonthMorphKey + /** The grab handle, which slides from the pane's seam to the foot of the grid. */ data object Handle : MonthMorphKey } diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthScreen.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthScreen.kt index 2675e6e..c69728c 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthScreen.kt @@ -591,6 +591,8 @@ internal fun MonthGrid( state: MonthUiState.Success, showWeekNumbers: Boolean, onOpenDay: (LocalDate) -> Unit, + /** See [MonthWeekRow]'s `selected`: an anchor for the morph, never a mark. */ + selected: LocalDate? = null, ) { Column( modifier = Modifier @@ -609,6 +611,7 @@ internal fun MonthGrid( inMonth = { it.month == month.month && it.year == month.year }, showWeekNumbers = showWeekNumbers, onOpenDay = onOpenDay, + selected = selected, modifier = Modifier .fillMaxWidth() .weight(1f), @@ -1003,6 +1006,7 @@ private fun SplitMonthBody( if (expanded) { SplitMonthExpanded( state = state, + selected = selected, slideDir = slideDir, showWeekNumbers = showWeekNumbers, swipeModifier = swipeModifier, @@ -1109,6 +1113,8 @@ private fun SplitMonthCollapsed( @Composable private fun SplitMonthExpanded( state: MonthUiState.Success, + /** Marked nowhere here; it only anchors the outline's morph. */ + selected: LocalDate, slideDir: Int, showWeekNumbers: Boolean, swipeModifier: Modifier, @@ -1123,18 +1129,19 @@ private fun SplitMonthExpanded( // of it, and the handle is the obvious thing to reach for on the way back. Column(Modifier.fillMaxSize().then(swipeModifier)) { AnimatedContent( - targetState = state, + targetState = state to selected, modifier = Modifier.weight(1f), - contentKey = { it.month }, + contentKey = { (s, _) -> s.month }, transitionSpec = { calendarSlideTransition(slideDir, slideSpec, fadeSpec, reduceMotion) }, label = "split-expanded-month-transition", - ) { s -> + ) { (s, sel) -> MonthGrid( state = s, showWeekNumbers = showWeekNumbers, onOpenDay = onPickDay, + selected = sel, ) } SplitExpandHandle(expanded = true, onToggle = onCollapse) @@ -1350,12 +1357,21 @@ private fun SplitDayCell( // is laid out where the collapsed cell actually is and simply fades in, // which is the only size it is ever true at. // - // It can stay plain content now that nothing renders into an overlay above - // the tree — see [morphElement]. It sits above its own pill by ordinary - // z-order and fades with the page it belongs to, in step with the rest. + // Tagged only while it is the selected day, and paired with an invisible + // stand-in over the same cell in the expanded grid, so it travels with its + // cell instead of fading in place. Tagged unconditionally it would put a + // key on all 42 cells against the one partner the expanded grid offers, + // and the other 41 would fly in from the layout origin. Box( Modifier .fillMaxSize() + .then( + if (isSelected) { + Modifier.morphBounds(MonthMorphKey.Selection(date)) + } else { + Modifier + }, + ) .drawBehind { val alpha = selection.value if (alpha <= 0f) return@drawBehind @@ -1647,6 +1663,13 @@ private fun MonthWeekRow( modifier: Modifier = Modifier, blankOutside: Boolean = false, labelMonthOnFirst: Boolean = false, + /** + * The selected day, when there is one. Draws *nothing* — it only places the + * invisible counterpart the compact grid's outline morphs against. Null for + * every style but the split style's expanded form, which composes no extra + * layer at all. + */ + selected: LocalDate? = null, ) { val dark = isSystemInDarkTheme() val laneCount = (week.spans.maxOfOrNull { it.lane } ?: -1) + 1 @@ -1846,6 +1869,29 @@ private fun MonthWeekRow( } } + // Invisible stand-in for the compact grid's selection outline, so it + // has somewhere to travel to and from. Its own layer rather than a + // child of the cell pill: that pill is itself a tagged piece, and a + // tag nested inside a tag travels twice. Padded to match the outline's + // own bounds over there, or it would arrive at the wrong size. + if (selected != null) { + Row(Modifier.matchParentSize()) { + week.days.forEach { d -> + if (d == selected && inMonth(d)) { + Box( + Modifier + .weight(1f) + .fillMaxHeight() + .padding(horizontal = CELL_GAP, vertical = 1.dp) + .morphBounds(MonthMorphKey.Selection(d)), + ) + } else { + Spacer(Modifier.weight(1f).fillMaxHeight()) + } + } + } + } + // Tap layer: in month view a tap on any day opens that day. Padded and // clipped to the background pill so the ripple matches it. A blanked // cell isn't part of this month, so it takes no taps either. From 6ab235e1d8200d66047e7be21fa09c76071f16c1 Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Mon, 20 Jul 2026 22:55:40 +0200 Subject: [PATCH 70/78] refactor(month): make the style picker read by looking, not comparing (#38) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The chooser showed a per-style blurb on every row and a generic feature summary under the preview. Move the selected style's own blurb under its preview instead, drop the generic summary, and reduce the rows to a single line — the preview is now bigger (280dp, wider) in the space that frees up. The blurbs now stand alone: Dense's copy referenced Continuous ("the same endless scroll…"), which made no sense once each shows in isolation. Rename the two vertical styles to say what they do — Continuous → "Scrolling months", Dense → "Seamless weeks". Co-Authored-By: Claude Opus 4.8 (1M context) --- .../calendula/ui/settings/MonthViewStylePicker.kt | 15 +++++++++------ app/src/main/res/values/strings.xml | 13 ++++++------- 2 files changed, 15 insertions(+), 13 deletions(-) diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/MonthViewStylePicker.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/MonthViewStylePicker.kt index e5802c6..53101bf 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/MonthViewStylePicker.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/MonthViewStylePicker.kt @@ -39,8 +39,9 @@ import kotlinx.datetime.DayOfWeek * closing on tap would hide the very thing the screen is for. Back exits, the * same as the App name picker, which stays open for the same reason. * - * Below the preview it is the family's standard picker shape: connected grouped - * rows with a tonal highlight and [SelectedCheck] on the current one. + * Under the preview sits the selected style's own one-line blurb, then the + * family's standard picker shape: connected single-line grouped rows with a + * tonal highlight and [SelectedCheck] on the current one. */ @Composable internal fun MonthViewStylePicker( @@ -59,7 +60,7 @@ internal fun MonthViewStylePicker( Box( modifier = Modifier .fillMaxWidth() - .padding(horizontal = 16.dp, vertical = 8.dp) + .padding(horizontal = 12.dp, vertical = 8.dp) .height(PREVIEW_HEIGHT), contentAlignment = Alignment.Center, ) { @@ -84,12 +85,14 @@ internal fun MonthViewStylePicker( } } } - PickerDescription(stringResource(R.string.settings_month_view_style_summary)) + // The selected style's own blurb lives here, under its preview, rather + // than on every row — the rows stay single-line and dense, and the words + // describe exactly what is being shown above. + PickerDescription(stringResource(selected.descriptionRes)) options.forEachIndexed { index, style -> val isSelected = style == selected GroupedRow( title = stringResource(style.labelRes), - summary = stringResource(style.descriptionRes), position = positionOf(index, options.size), selected = isSelected, trailing = if (isSelected) { @@ -105,5 +108,5 @@ internal fun MonthViewStylePicker( } } -private val PREVIEW_HEIGHT = 240.dp +private val PREVIEW_HEIGHT = 280.dp private val PREVIEW_SHAPE = RoundedCornerShape(12.dp) diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 79c5155..ef9bfce 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -375,15 +375,14 @@ Month view Month view style - Choose how the month view is laid out and how you move through it. Pages - One month at a time. Swipe sideways to change month. - Continuous - Scroll up and down through the months. Each month keeps to itself under its own heading, with no days borrowed from its neighbours. - Dense - The same endless scroll with the seams taken out — one unbroken run of weeks, months flowing into each other. + One month fills the screen. Swipe left or right to change month. + Scrolling months + Each month sits under its own heading, with a little space setting it apart from the next. + Seamless weeks + The weeks run on without a break, each month flowing straight into the next with no gap between them. Split - A compact grid with dots for events, and the day you tap listed underneath. + A compact grid marks the days that have events, and the day you tap is listed underneath. Nothing scheduled Show the whole month Show the day\'s events From 28fb7404d833064c1282a74bb634b85b8f19cbbd Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Mon, 20 Jul 2026 23:20:21 +0200 Subject: [PATCH 71/78] fix(month): point the split slide the way the month moves (#53) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Tapping a greyed leading/trailing day in the split grid follows the selection to that day's own month, but selectDate never set the slide direction — so the incoming page reused whatever the last swipe left in slideDir and could travel backwards while navigation moved forwards. Wrap selectDate so a tap that crosses months points slideDir at the month it is actually heading to. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../jeanlucmakiola/calendula/ui/month/MonthScreen.kt | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthScreen.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthScreen.kt index c69728c..2022ff3 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthScreen.kt @@ -297,6 +297,15 @@ fun MonthScreen( viewModel.goToDate(target) } } + // Selecting a day in the split grid can cross into a neighbour month — a + // tapped leading/trailing day follows to its own month. Point the slide the + // way the month is actually moving, so the incoming page travels the right + // direction instead of reusing whatever the last swipe left in slideDir. + val selectDay: (LocalDate) -> Unit = { date -> + val target = YearMonth(date.year, date.month) + if (target != month) slideDir = if (target < month) -1 else 1 + viewModel.selectDate(date) + } ModalNavigationDrawer( drawerState = drawerState, @@ -377,7 +386,7 @@ fun MonthScreen( onSwipeNext = goNext, onSwipePrev = goPrev, onRetry = jumpToToday, - onSelectDay = viewModel::selectDate, + onSelectDay = selectDay, onOpenDay = onOpenDay, onEventClick = onEventClick, onCreateEvent = { onCreateEvent(it, null) }, From 052335e842b2b6482fd8c4124a3f88d6d4c39e4f Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Mon, 20 Jul 2026 23:20:34 +0200 Subject: [PATCH 72/78] fix(month): carry the scroll position across a style change (#38) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The scrolling styles navigate by scroll position and never set the paged month, so it sat wherever paged navigation last left it — today's month on a fresh open. Switching to a paged style, or reseeding the other scrolling style's list state (both read off it), snapped back there instead of holding the month on screen. Track the paged month to the visible month while a scrolling style is up, realigning the split selection alongside it so landing on Split shows a live day in the month you were viewing rather than a stale, off-month one. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../calendula/ui/month/MonthScreen.kt | 13 +++++++++++++ .../calendula/ui/month/MonthViewModel.kt | 15 +++++++++++++++ 2 files changed, 28 insertions(+) diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthScreen.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthScreen.kt index 2022ff3..41a4d8f 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthScreen.kt @@ -239,6 +239,19 @@ fun MonthScreen( .collect { (first, last) -> viewModel.onVisibleMonthsChanged(first, last) } } + // Keep the paged month tracking the scrolling grid. The scrolling styles + // navigate by scroll position and never touch the paged month otherwise, so + // without this it stays wherever paged navigation last left it (today's + // month, on a fresh open) — and switching to a paged style, or reseeding the + // other scrolling style's list state, which both read off it, would snap + // back there rather than staying on the month you were looking at. + LaunchedEffect(listState, scrolling, dense, weekStart) { + if (!scrolling) return@LaunchedEffect + snapshotFlow { visibleMonth } + .distinctUntilChanged() + .collect { viewModel.syncScrollMonth(it) } + } + val isOnCurrentMonth = titleMonth == YearMonth(today.year, today.month) // Continuous names the month on the block's own sticky header, so the bar 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 dd1ba3f..c4cab41 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 @@ -284,6 +284,21 @@ class MonthViewModel @Inject constructor( _selectedDate.value = todayDate } + /** + * Track [_month] to the month a scrolling style is showing. Those styles + * navigate by scroll position and never set [_month] themselves, so it would + * otherwise sit wherever paged navigation last left it (today's month, on a + * fresh open) — and a switch to a paged style, or a reseed of the other + * scrolling style's list, would jump there instead of holding position. The + * selection is realigned alongside it so that landing on the Split style + * shows a live day in the month on screen rather than a stale, off-month one. + */ + fun syncScrollMonth(ym: YearMonth) { + if (_month.value == ym) return + _month.value = ym + _selectedDate.value = selectionForMonth(ym, todayDate) + } + /** Jump to the month containing [date] (drawer jump-to-date). */ fun goToDate(date: LocalDate) { _month.value = YearMonth(date.year, date.month) From 3e821c909230cece246f3bd2e71f7c34d9cb156a Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Fri, 24 Jul 2026 17:12:29 +0200 Subject: [PATCH 73/78] fix(pickers): keep full-screen picker bar icons theme-matched (#70) Bump floret-kit to the fix that drives each picker Dialog window's status/navigation-bar icon appearance from the runtime theme. The picker Dialog owns its own Window, seeded from the XML theme rather than the activity's runtime edge-to-edge state, so in dark theme the status-bar icons went black and near-invisible on the dark picker (Codeberg #70). Co-Authored-By: Claude Opus 4.8 (1M context) --- floret-kit | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/floret-kit b/floret-kit index 75b90d1..9bbef91 160000 --- a/floret-kit +++ b/floret-kit @@ -1 +1 @@ -Subproject commit 75b90d1b3b7b1d6e050cc0ee961d4a8867abd60d +Subproject commit 9bbef911c0b0c3f2949240d2fea1cbc256a25127 From 75f699b714de57a47238fa65302c803b01686e8e Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Fri, 24 Jul 2026 17:24:57 +0200 Subject: [PATCH 74/78] chore(floret-kit): re-point submodule to merged main (#70) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit floret-kit#3 merged to main; move the pointer from the fix branch commit to the main merge commit. No code change — same tree. Co-Authored-By: Claude Opus 4.8 (1M context) --- floret-kit | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/floret-kit b/floret-kit index 9bbef91..b4d3ead 160000 --- a/floret-kit +++ b/floret-kit @@ -1 +1 @@ -Subproject commit 9bbef911c0b0c3f2949240d2fea1cbc256a25127 +Subproject commit b4d3ead8f0fe1e042c1cd7f350ff7132f22189b3 From 9e28fa4981d7f04fb8afbae93bf4f19a3067cc26 Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Fri, 24 Jul 2026 17:37:59 +0200 Subject: [PATCH 75/78] fix(intents): accept item-typed INSERT / INSERT_OR_EDIT for "Add to calendar" (#74) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Other apps' "Add to calendar" / "Save to calendar" actions commonly fire the canonical insert intent — ACTION_INSERT with setType("vnd.android.cursor.item/ event") — the singular *item* MIME type (Android's own docs example; used by e.g. DB Navigator). Calendula advertised INSERT only on the *dir* MIME type, so it never matched: absent from the chooser, and a silent no-op when it was the only calendar app installed. Add the item-typed INSERT to the events filter, plus ACTION_INSERT_OR_EDIT (the third "add to calendar" action AOSP and Google Calendar register). The runtime parser already treats any ACTION_INSERT as create; teach insertFormOrNull / editEventKeyOrNull about INSERT_OR_EDIT so an id-less one is a create and an id-carrying one opens the edit form. Co-Authored-By: Claude Opus 4.8 (1M context) --- CHANGELOG.md | 7 ++++ app/src/main/AndroidManifest.xml | 37 ++++++++++++++----- .../jeanlucmakiola/calendula/MainActivity.kt | 13 ++++--- 3 files changed, 43 insertions(+), 14 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 49f8808..8aafac8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -32,6 +32,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 Now a bigger widget gets bigger, more readable type and roomier rows, while the default size looks exactly as before — no new setting; it follows the size you already chose ([#51]). +- Calendula now appears under other apps' "Add to calendar" / "Save to calendar" + actions. Some apps (e.g. DB Navigator) fire the widely-used "insert event" + intent with the singular `vnd.android.cursor.item/event` type, which Calendula + didn't advertise — so it was left out of the chooser, and if it was your only + calendar app the save silently did nothing. It now accepts that form, plus the + `INSERT_OR_EDIT` action, and opens the new event prefilled for review ([#74]). ## [2.16.0] — 2026-07-17 @@ -1079,3 +1085,4 @@ automatically, with zero telemetry and no internet permission. [#53]: https://codeberg.org/jlmakiola/calendula/issues/53 [#60]: https://codeberg.org/jlmakiola/calendula/issues/60 [#65]: https://codeberg.org/jlmakiola/calendula/issues/65 +[#74]: https://codeberg.org/jlmakiola/calendula/issues/74 diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 7f73919..3de3eac 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -123,11 +123,13 @@ + to the same prefilled create form. (The far more common *item*- + typed INSERT — the form the Android docs' example and apps like + DB Navigator use — is the item filter below, issue #74.) --> @@ -135,14 +137,31 @@ - + + + diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/MainActivity.kt b/app/src/main/java/de/jeanlucmakiola/calendula/MainActivity.kt index 1177165..0a0c20f 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/MainActivity.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/MainActivity.kt @@ -222,10 +222,12 @@ class MainActivity : AppCompatActivity() { * [buildInsertEventForm]. */ private fun Intent.insertFormOrNull(): EventForm? { - // ACTION_EDIT on an existing event routes to the edit form instead - // ([editEventKeyOrNull]); only an id-less EDIT is a create. + // ACTION_EDIT / ACTION_INSERT_OR_EDIT on an existing event route to the + // edit form instead ([editEventKeyOrNull]); an id-less one is a create, + // as is any plain ACTION_INSERT. val isCreate = action == Intent.ACTION_INSERT || - (action == Intent.ACTION_EDIT && editEventKeyOrNull() == null) + ((action == Intent.ACTION_EDIT || action == Intent.ACTION_INSERT_OR_EDIT) && + editEventKeyOrNull() == null) if (!isCreate) return null return buildInsertEventForm( beginMillis = longExtraOrNull(CalendarContract.EXTRA_EVENT_BEGIN_TIME), @@ -344,10 +346,11 @@ class MainActivity : AppCompatActivity() { * occurrence's times as `EXTRA_EVENT_BEGIN_TIME` / `EXTRA_EVENT_END_TIME` * when it has them, otherwise we carry [NO_OCCURRENCE_TIME] and * [EventEditViewModel.openForEdit] falls back to the event row's own - * DTSTART/DTEND. An id-less `ACTION_EDIT` is a create instead ([insertFormOrNull]). + * DTSTART/DTEND. An id-less `ACTION_EDIT` — or `ACTION_INSERT_OR_EDIT`, which + * some apps fire — is a create instead ([insertFormOrNull]). */ private fun Intent.editEventKeyOrNull(): LongArray? { - if (action != Intent.ACTION_EDIT) return null + if (action != Intent.ACTION_EDIT && action != Intent.ACTION_INSERT_OR_EDIT) return null val uri = data ?: return null if (uri.host != CALENDAR_PROVIDER_HOST) return null val segments = uri.pathSegments From 7b3893ddc6edde478cd2a0926e67e3430dbc23e6 Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Fri, 24 Jul 2026 17:40:27 +0200 Subject: [PATCH 76/78] feat(intents): open .ics/.vcs handed over as application/octet-stream (#74) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mail clients, browsers and file managers frequently label a calendar attachment as a generic download (application/octet-stream) rather than text/calendar, so the MIME-typed VIEW filter missed them. Add a dedicated filter that matches those by .ics/.vcs extension via pathPattern — kept separate so the path constraint can't narrow the reliable MIME-typed filter. The import handler already ignores the declared MIME, so a let-through file imports normally. Best-effort: reliable for file:// and content:// whose path carries the name; nameless content:// URIs still fall back to the MIME filter. Co-Authored-By: Claude Opus 4.8 (1M context) --- CHANGELOG.md | 4 ++++ app/src/main/AndroidManifest.xml | 22 ++++++++++++++++++++++ 2 files changed, 26 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8aafac8..724bb14 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -38,6 +38,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 didn't advertise — so it was left out of the chooser, and if it was your only calendar app the save silently did nothing. It now accepts that form, plus the `INSERT_OR_EDIT` action, and opens the new event prefilled for review ([#74]). +- Opening a `.ics`/`.vcs` file now works even when another app hands it over + mislabelled as a generic download (`application/octet-stream`), as some mail + clients, browsers and file managers do — Calendula recognises it by its file + extension instead of relying on the declared type ([#74]). ## [2.16.0] — 2026-07-17 diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 3de3eac..8e0df89 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -112,6 +112,28 @@ + + + + + + + + + + + + + From 521c0ffd78b0d82cd2854057c292be072a323d65 Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Fri, 24 Jul 2026 18:20:07 +0200 Subject: [PATCH 77/78] i18n: offer Arabic in the language picker MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Weblate merge added a values-ar translation (~50% complete, RTL — the app already declares supportsRtl). Add the matching line so Arabic is selectable in both the in-app picker and the system per-app language settings. Russian and Portuguese stay out for now: at 12% and <1% they're below the usable bar (the lowest currently-offered language, zh-CN, sits at ~24%). Co-Authored-By: Claude Opus 4.8 (1M context) --- app/src/main/res/xml/locales_config.xml | 1 + 1 file changed, 1 insertion(+) diff --git a/app/src/main/res/xml/locales_config.xml b/app/src/main/res/xml/locales_config.xml index 385bf1f..a6949ed 100644 --- a/app/src/main/res/xml/locales_config.xml +++ b/app/src/main/res/xml/locales_config.xml @@ -9,6 +9,7 @@ --> + From 108a1890bea256033d13eed6f19fdc883548f4c9 Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Fri, 24 Jul 2026 21:47:44 +0200 Subject: [PATCH 78/78] docs(changelog): cut 2.16.0 Consolidate the release under a single 2.16.0 heading dated today and fold in the work that had accumulated in Unreleased. Add the entries the log had been missing as PRs merged: app-name toggle (#44), the Arabic/French/Italian translations, the custom recurrence picker redesign + end-date fix (#42), multi-day agenda events (#83), and dark-theme picker bar icons (#70). Regenerate the fastlane per-version changelog. Co-Authored-By: Claude Opus 4.8 (1M context) --- CHANGELOG.md | 66 ++++++++----- .../android/en-US/changelogs/21600.txt | 98 +++++++++++++++---- 2 files changed, 125 insertions(+), 39 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 724bb14..c16f6ae 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [2.16.0] — 2026-07-24 + ### Added - Choose how the month view is laid out. A new **Month view style** setting (Settings → Views) offers three ways to read a month, each shown with a @@ -24,28 +26,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 The Agenda view is untouched by this and stays available in all three styles — the split layout lists a single day, while Agenda remains a rolling multi-day window with its own range settings. - -### Fixed -- The "Upcoming" agenda widget now scales its text and rows to the size you give - it. Previously it was laid out once for the smallest size and simply stretched - when enlarged, so the text stayed small no matter how big you made the widget. - Now a bigger widget gets bigger, more readable type and roomier rows, while the - default size looks exactly as before — no new setting; it follows the size you - already chose ([#51]). -- Calendula now appears under other apps' "Add to calendar" / "Save to calendar" - actions. Some apps (e.g. DB Navigator) fire the widely-used "insert event" - intent with the singular `vnd.android.cursor.item/event` type, which Calendula - didn't advertise — so it was left out of the chooser, and if it was your only - calendar app the save silently did nothing. It now accepts that form, plus the - `INSERT_OR_EDIT` action, and opens the new event prefilled for review ([#74]). -- Opening a `.ics`/`.vcs` file now works even when another app hands it over - mislabelled as a generic download (`application/octet-stream`), as some mail - clients, browsers and file managers do — Calendula recognises it by its file - extension instead of relying on the declared type ([#74]). - -## [2.16.0] — 2026-07-17 - -### Added - Give an event its own time zone. A new **Time zone** field (under "more fields" in the event form) pins an event to a specific zone, so a call set for 8:00 AM in New York stays 8:00 AM in New York wherever you open it — and keeps @@ -63,6 +43,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 today icon in the top bar — always there, on today or not, matching the familiar calendar-app pattern. Leave it off to keep the floating button as before ([#60]). +- Choose what Calendula calls itself on your home screen. A new **App name** + setting (Settings → Appearance) switches the launcher label between + **Calendula** and **Calendar**, for launchers that can't rename apps + themselves. Pick from a full-screen chooser that previews both names as + launcher marks; the change applies at once ([#44]). +- Calendula now speaks **Arabic**, laid out right-to-left, and its French and + Italian translations have been brought up to date — thanks to the community + translators on Weblate. Pick a language under Settings → Language (or leave it + on the system default). ### Changed - Dates in the Month, Week and Day title bars now follow your language and @@ -81,6 +70,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 "24. Jun – 31. Jun" restated the day numbers already printed in the column headers right below it, in the widest string in the bar. A week that straddles two months keeps the outgoing month until it is fully gone ([#60]). +- The custom recurrence picker has been redesigned and tightened up. As you + build a rule — "every 2 weeks on Mon & Wed, until a date" — the live summary + now describes exactly what will be saved rather than a near-copy that could + drift from it, the amount fields accept being left blank (reading as their + shown default instead of greying out OK), and the read-out no longer jumps + around as you tap weekdays ([#42]). ### Fixed - An all-day event no longer shows up again the day after it happened. In time @@ -90,6 +85,30 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 window reached back across that boundary and pulled the event forward onto "today". Each all-day event now lists only on the day it actually falls on ([#65]). +- A multi-day event now shows under every day it spans in the Agenda, not just + its first day, so a trip or a multi-day booking appears on each day it covers. +- The "Upcoming" agenda widget now scales its text and rows to the size you give + it. Previously it was laid out once for the smallest size and simply stretched + when enlarged, so the text stayed small no matter how big you made the widget. + Now a bigger widget gets bigger, more readable type and roomier rows, while the + default size looks exactly as before — no new setting; it follows the size you + already chose ([#51]). +- Calendula now appears under other apps' "Add to calendar" / "Save to calendar" + actions. Some apps (e.g. DB Navigator) fire the widely-used "insert event" + intent with the singular `vnd.android.cursor.item/event` type, which Calendula + didn't advertise — so it was left out of the chooser, and if it was your only + calendar app the save silently did nothing. It now accepts that form, plus the + `INSERT_OR_EDIT` action, and opens the new event prefilled for review ([#74]). +- Opening a `.ics`/`.vcs` file now works even when another app hands it over + mislabelled as a generic download (`application/octet-stream`), as some mail + clients, browsers and file managers do — Calendula recognises it by its file + extension instead of relying on the declared type ([#74]). +- A recurrence end date no longer lands a day late. West of UTC, setting a rule + to end "until" a given day could save and show the day after the one picked; + the end date now reads back as chosen ([#42]). +- The status- and navigation-bar icons stay legible over full-screen pickers in + dark theme. They could render dark-on-dark — a near-invisible black clock + against the dark picker — instead of switching to light ([#70]). ## [2.15.0] — 2026-07-15 @@ -1090,3 +1109,6 @@ automatically, with zero telemetry and no internet permission. [#60]: https://codeberg.org/jlmakiola/calendula/issues/60 [#65]: https://codeberg.org/jlmakiola/calendula/issues/65 [#74]: https://codeberg.org/jlmakiola/calendula/issues/74 +[#42]: https://codeberg.org/jlmakiola/calendula/issues/42 +[#44]: https://codeberg.org/jlmakiola/calendula/issues/44 +[#70]: https://codeberg.org/jlmakiola/calendula/issues/70 diff --git a/fastlane/metadata/android/en-US/changelogs/21600.txt b/fastlane/metadata/android/en-US/changelogs/21600.txt index b96a9c1..72f1f11 100644 --- a/fastlane/metadata/android/en-US/changelogs/21600.txt +++ b/fastlane/metadata/android/en-US/changelogs/21600.txt @@ -1,21 +1,46 @@ ### Added -- Give an event its own time zone. A new Time zone field (under "more fields" in - the event form) pins an event to a specific zone, so a call set for 8:00 AM in - New York stays 8:00 AM in New York wherever you open it, and keeps tracking - that zone across daylight-saving changes. The form shows the local equivalent - under the times, and the event's details keep your local time first with the - original noted beneath. Pick a zone from a searchable full-screen picker — by - city, IANA id, or abbreviation like "CEST". All-day events stay date-anchored - and carry no zone ([#31]). -- Put the "jump to today" button in the toolbar. A new Today button in toolbar - setting (Settings → Appearance, off by default) swaps the floating corner - button for a permanent today icon in the top bar — always there, matching the - familiar calendar-app pattern. Leave it off to keep the floating button ([#60]). -- Show the app as "Calendar" in your launcher. A new App name setting - (Settings → Appearance) switches the launcher label from "Calendula" to the - generic "Calendar" for anyone who prefers it — handy on launchers that can't - rename apps themselves. Only the launcher name changes; your home-screen icon - may move to a new spot after switching ([#44]). +- Choose how the month view is laid out. A new **Month view style** setting + (Settings → Views) offers three ways to read a month, each shown with a + preview of the layout it produces: + - **Pages** — what you have today: one month at a time, swiped sideways. + - **Continuous** — scroll up and down through the weeks without a break + between months. Because the weeks run on unbroken, no month is cut off and + no day appears twice, where paging repeats a boundary week at the end of one + month and the start of the next. The 1st of each month names itself so you + always know where you are, and the title bar keeps up as you scroll ([#38]). + - **Split** — a compact grid showing coloured dots for the days that have + something on them, with the day you tap listed in full underneath. Tap the + date above the list to open the whole day ([#53]). + + The Agenda view is untouched by this and stays available in all three styles — + the split layout lists a single day, while Agenda remains a rolling multi-day + window with its own range settings. +- Give an event its own time zone. A new **Time zone** field (under "more + fields" in the event form) pins an event to a specific zone, so a call set for + 8:00 AM in New York stays 8:00 AM in New York wherever you open it — and keeps + tracking that zone across daylight-saving changes instead of drifting an hour. + The form edits the event in its own zone and shows the local equivalent under + the times ("2:00 PM – 3:00 PM your time"); the event's details keep your local + time first and note the original beneath it, so both are always clear. Pick a + zone from a full-screen picker with your device zone and recent choices on top, + searching by city ("new york"), IANA id ("europe/berlin"), or abbreviation + ("CEST") to gather every matching zone at once. All-day events stay + date-anchored and carry no zone, as before ([#31]). +- Put the "jump to today" button in the toolbar. A new **Today button in + toolbar** setting (Settings → Appearance, off by default) swaps the floating + button that fades into the corner while you're away from today for a permanent + today icon in the top bar — always there, on today or not, matching the + familiar calendar-app pattern. Leave it off to keep the floating button as + before ([#60]). +- Choose what Calendula calls itself on your home screen. A new **App name** + setting (Settings → Appearance) switches the launcher label between + **Calendula** and **Calendar**, for launchers that can't rename apps + themselves. Pick from a full-screen chooser that previews both names as + launcher marks; the change applies at once ([#44]). +- Calendula now speaks **Arabic**, laid out right-to-left, and its French and + Italian translations have been brought up to date — thanks to the community + translators on Weblate. Pick a language under Settings → Language (or leave it + on the system default). ### Changed - Dates in the Month, Week and Day title bars now follow your language and @@ -34,4 +59,43 @@ "24. Jun – 31. Jun" restated the day numbers already printed in the column headers right below it, in the widest string in the bar. A week that straddles two months keeps the outgoing month until it is fully gone ([#60]). +- The custom recurrence picker has been redesigned and tightened up. As you + build a rule — "every 2 weeks on Mon & Wed, until a date" — the live summary + now describes exactly what will be saved rather than a near-copy that could + drift from it, the amount fields accept being left blank (reading as their + shown default instead of greying out OK), and the read-out no longer jumps + around as you tap weekdays ([#42]). + +### Fixed +- An all-day event no longer shows up again the day after it happened. In time + zones east of UTC, an all-day event — a birthday, say — set for one day also + appeared under the *next* day's heading in the Agenda (and the agenda widget), + because all-day events are anchored to UTC midnight and the following day's + window reached back across that boundary and pulled the event forward onto + "today". Each all-day event now lists only on the day it actually falls on + ([#65]). +- A multi-day event now shows under every day it spans in the Agenda, not just + its first day, so a trip or a multi-day booking appears on each day it covers. +- The "Upcoming" agenda widget now scales its text and rows to the size you give + it. Previously it was laid out once for the smallest size and simply stretched + when enlarged, so the text stayed small no matter how big you made the widget. + Now a bigger widget gets bigger, more readable type and roomier rows, while the + default size looks exactly as before — no new setting; it follows the size you + already chose ([#51]). +- Calendula now appears under other apps' "Add to calendar" / "Save to calendar" + actions. Some apps (e.g. DB Navigator) fire the widely-used "insert event" + intent with the singular `vnd.android.cursor.item/event` type, which Calendula + didn't advertise — so it was left out of the chooser, and if it was your only + calendar app the save silently did nothing. It now accepts that form, plus the + `INSERT_OR_EDIT` action, and opens the new event prefilled for review ([#74]). +- Opening a `.ics`/`.vcs` file now works even when another app hands it over + mislabelled as a generic download (`application/octet-stream`), as some mail + clients, browsers and file managers do — Calendula recognises it by its file + extension instead of relying on the declared type ([#74]). +- A recurrence end date no longer lands a day late. West of UTC, setting a rule + to end "until" a given day could save and show the day after the one picked; + the end date now reads back as chosen ([#42]). +- The status- and navigation-bar icons stay legible over full-screen pickers in + dark theme. They could render dark-on-dark — a near-invisible black clock + against the dark picker — instead of switching to light ([#70]).