From 47e8036000a59c61d5b0e75fa3ea344045e45469 Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Sat, 27 Jun 2026 13:45:01 +0200 Subject: [PATCH 01/26] feat(settings): allow week to start on any day Replace the AUTO/MONDAY/SUNDAY week-start enum with a sealed type (Auto + Day(DayOfWeek)) so users can pick any of the seven days as the first day of the week. The picker now lists "Automatic" plus all seven localised weekday names; labels come from java.time display names rather than per-day string resources. Stored values round-trip by DayOfWeek.name, so the legacy MONDAY/SUNDAY preferences migrate transparently. Garbage values fall back to Auto. Closes #3 (Codeberg) Co-Authored-By: Claude Opus 4.8 (1M context) --- .../calendula/data/prefs/SettingsPrefs.kt | 42 +++++++++++++++---- .../calendula/ui/settings/SettingsScreen.kt | 23 ++++++---- .../calendula/ui/settings/SettingsUiState.kt | 2 +- app/src/main/res/values-de/strings.xml | 2 - app/src/main/res/values/strings.xml | 2 - .../calendula/data/prefs/SettingsPrefsTest.kt | 42 +++++++++++++++---- 6 files changed, 83 insertions(+), 30 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 2b3cb82..8585832 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 @@ -19,18 +19,23 @@ import javax.inject.Singleton /** Light/dark override. SYSTEM follows the device setting. */ enum class ThemeMode { SYSTEM, LIGHT, DARK } -/** Week-start override. AUTO derives the first day from the active locale. */ -enum class WeekStartPref { AUTO, MONDAY, SUNDAY } +/** + * Week-start override. [Auto] derives the first day from the active locale; + * [Day] pins a specific weekday (any of the seven). + */ +sealed interface WeekStartPref { + data object Auto : WeekStartPref + data class Day(val day: DayOfWeek) : WeekStartPref +} /** - * Resolve the preference to a concrete first-day-of-week. AUTO reads the - * locale's convention (e.g. Monday in DE, Sunday in en-US). + * Resolve the preference to a concrete first-day-of-week. [WeekStartPref.Auto] + * reads the locale's convention (e.g. Monday in DE, Sunday in en-US). */ fun WeekStartPref.resolveFirstDay(locale: Locale): DayOfWeek = when (this) { - WeekStartPref.MONDAY -> DayOfWeek.MONDAY - WeekStartPref.SUNDAY -> DayOfWeek.SUNDAY + is WeekStartPref.Day -> day // java.time.DayOfWeek.value is ISO 1..7 (Mon..Sun) — same numbering kotlinx uses. - WeekStartPref.AUTO -> DayOfWeek(WeekFields.of(locale).firstDayOfWeek.value) + WeekStartPref.Auto -> DayOfWeek(WeekFields.of(locale).firstDayOfWeek.value) } /** @@ -55,7 +60,7 @@ class SettingsPrefs @Inject constructor( } val weekStart: Flow = store.data.map { prefs -> - prefs[WEEK_START_KEY].toEnum(WeekStartPref.AUTO) + parseWeekStart(prefs[WEEK_START_KEY]) } suspend fun setThemeMode(mode: ThemeMode) { @@ -67,7 +72,7 @@ class SettingsPrefs @Inject constructor( } suspend fun setWeekStart(pref: WeekStartPref) { - store.edit { it[WEEK_START_KEY] = pref.name } + store.edit { it[WEEK_START_KEY] = pref.storageValue() } } /** @@ -331,6 +336,25 @@ private fun MutableMap.applyOverride( } } +/** Sentinel stored for [WeekStartPref.Auto]; days store their [DayOfWeek.name]. */ +private const val WEEK_START_AUTO = "AUTO" + +private fun WeekStartPref.storageValue(): String = when (this) { + WeekStartPref.Auto -> WEEK_START_AUTO + is WeekStartPref.Day -> day.name +} + +/** + * Parse the stored week-start value. "AUTO"/null/garbage → [WeekStartPref.Auto]; + * a [DayOfWeek] name → [WeekStartPref.Day]. The legacy "MONDAY"/"SUNDAY" enum + * values migrate transparently, since both are valid day names. + */ +private fun parseWeekStart(stored: String?): WeekStartPref = when (stored) { + null, WEEK_START_AUTO -> WeekStartPref.Auto + else -> DayOfWeek.entries.firstOrNull { it.name == stored } + ?.let { WeekStartPref.Day(it) } ?: WeekStartPref.Auto +} + private const val NONE = "none" private const val ENTRY_SEP = ";" private const val KEY_VALUE_SEP = "=" 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 21a8f7a..062b7b0 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 @@ -105,7 +105,10 @@ import de.jeanlucmakiola.calendula.ui.common.TimePickerAlert import de.jeanlucmakiola.calendula.ui.common.positionOf import de.jeanlucmakiola.calendula.ui.common.reminderLeadTimeLabel import de.jeanlucmakiola.calendula.ui.common.rememberCalendarSlideSpec +import de.jeanlucmakiola.calendula.ui.common.currentLocale +import kotlinx.datetime.DayOfWeek import kotlinx.datetime.LocalTime +import java.time.format.TextStyle as JavaTextStyle import java.util.Calendar /** The settings sub-screens reached from the hub's category rows. */ @@ -484,7 +487,7 @@ private fun AppearanceScreen( if (showWeekStart) { OptionPicker( title = stringResource(R.string.settings_week_start), - options = WeekStartPref.entries, + options = WEEK_START_OPTIONS, selected = state.weekStart, label = { weekStartLabel(it) }, onSelect = viewModel::setWeekStart, @@ -993,14 +996,18 @@ private fun themeLabel(mode: ThemeMode): String = stringResource( }, ) +/** Picker options: "Follow system" first, then Monday…Sunday in ISO order. */ +private val WEEK_START_OPTIONS: List = + listOf(WeekStartPref.Auto) + DayOfWeek.entries.map { WeekStartPref.Day(it) } + @Composable -private fun weekStartLabel(pref: WeekStartPref): String = stringResource( - when (pref) { - WeekStartPref.AUTO -> R.string.settings_week_start_auto - WeekStartPref.MONDAY -> R.string.settings_week_start_monday - WeekStartPref.SUNDAY -> R.string.settings_week_start_sunday - }, -) +private fun weekStartLabel(pref: WeekStartPref): String = when (pref) { + WeekStartPref.Auto -> stringResource(R.string.settings_week_start_auto) + // Localised full weekday name, so any of the seven days reads naturally + // without a per-day string resource. + is WeekStartPref.Day -> java.time.DayOfWeek.of(pref.day.ordinal + 1) + .getDisplayName(JavaTextStyle.FULL, currentLocale()) +} @Composable private fun languageLabel(tag: String?): String = 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 1dac3bb..b625722 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 @@ -17,7 +17,7 @@ data class SettingsUiState( val themeMode: ThemeMode = ThemeMode.SYSTEM, val dynamicColor: Boolean = true, val dynamicColorAvailable: Boolean = true, - val weekStart: WeekStartPref = WeekStartPref.AUTO, + val weekStart: WeekStartPref = WeekStartPref.Auto, /** The calendar view the app opens on, and the home of the view back stack (M1). */ val defaultView: CalendarView = CalendarView.Week, /** Optional event-form fields shown by default (rest behind "more fields"). */ diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index aa24a30..b133ec8 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -276,8 +276,6 @@ Erfordert Android 12 oder neuer Wochenstart Automatisch - Montag - Sonntag Termin-Formular Standardmäßig angezeigte Felder — alles Weitere liegt hinter \"Weitere Felder\" Farben auf nicht unterstützten Kalendern erlauben diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 644286f..8851159 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -269,8 +269,6 @@ Requires Android 12 or newer Week starts on Automatic - Monday - Sunday New event form Fields shown by default — everything else sits behind \"More fields\" Allow colors on unsupported calendars 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 dee5554..7d7d052 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 @@ -25,7 +25,7 @@ class SettingsPrefsTest { val prefs = SettingsPrefs(newDataStore(tempDir)) assertThat(prefs.themeMode.first()).isEqualTo(ThemeMode.SYSTEM) assertThat(prefs.dynamicColor.first()).isTrue() - assertThat(prefs.weekStart.first()).isEqualTo(WeekStartPref.AUTO) + assertThat(prefs.weekStart.first()).isEqualTo(WeekStartPref.Auto) } @Test @@ -43,10 +43,34 @@ class SettingsPrefsTest { } @Test - fun `week start round-trips`(@TempDir tempDir: Path) = runTest { + fun `week start round-trips any day`(@TempDir tempDir: Path) = runTest { val prefs = SettingsPrefs(newDataStore(tempDir)) - prefs.setWeekStart(WeekStartPref.SUNDAY) - assertThat(prefs.weekStart.first()).isEqualTo(WeekStartPref.SUNDAY) + prefs.setWeekStart(WeekStartPref.Day(DayOfWeek.SATURDAY)) + assertThat(prefs.weekStart.first()).isEqualTo(WeekStartPref.Day(DayOfWeek.SATURDAY)) + } + + @Test + fun `legacy MONDAY week-start value migrates to Day`(@TempDir tempDir: Path) = runTest { + val store = newDataStore(tempDir) + val prefs = SettingsPrefs(store) + store.updateData { p -> + val m = p.toMutablePreferences() + m[SettingsPrefs.WEEK_START_KEY] = "MONDAY" + m + } + assertThat(prefs.weekStart.first()).isEqualTo(WeekStartPref.Day(DayOfWeek.MONDAY)) + } + + @Test + fun `garbage week-start value falls back to Auto`(@TempDir tempDir: Path) = runTest { + val store = newDataStore(tempDir) + val prefs = SettingsPrefs(store) + store.updateData { p -> + val m = p.toMutablePreferences() + m[SettingsPrefs.WEEK_START_KEY] = "FUNDAY" + m + } + assertThat(prefs.weekStart.first()).isEqualTo(WeekStartPref.Auto) } @Test @@ -252,13 +276,15 @@ class SettingsPrefsTest { @Test fun `explicit week-start prefs resolve regardless of locale`() { - assertThat(WeekStartPref.MONDAY.resolveFirstDay(Locale.US)).isEqualTo(DayOfWeek.MONDAY) - assertThat(WeekStartPref.SUNDAY.resolveFirstDay(Locale.GERMANY)).isEqualTo(DayOfWeek.SUNDAY) + assertThat(WeekStartPref.Day(DayOfWeek.MONDAY).resolveFirstDay(Locale.US)) + .isEqualTo(DayOfWeek.MONDAY) + assertThat(WeekStartPref.Day(DayOfWeek.SATURDAY).resolveFirstDay(Locale.GERMANY)) + .isEqualTo(DayOfWeek.SATURDAY) } @Test fun `auto week start follows the locale convention`() { - assertThat(WeekStartPref.AUTO.resolveFirstDay(Locale.GERMANY)).isEqualTo(DayOfWeek.MONDAY) - assertThat(WeekStartPref.AUTO.resolveFirstDay(Locale.US)).isEqualTo(DayOfWeek.SUNDAY) + assertThat(WeekStartPref.Auto.resolveFirstDay(Locale.GERMANY)).isEqualTo(DayOfWeek.MONDAY) + assertThat(WeekStartPref.Auto.resolveFirstDay(Locale.US)).isEqualTo(DayOfWeek.SUNDAY) } } From c91608e48aa04fab613a33e8aaef9ddc2048c506 Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Sat, 27 Jun 2026 14:02:22 +0200 Subject: [PATCH 02/26] feat(settings): add 12/24-hour time format toggle MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a TimeFormatPref (Auto/12h/24h) preference. Auto follows the device's 24-hour system setting; the others force a clock app-wide. The resolved convention is provided once at the app root via LocalUse24HourFormat, so every in-app time label reads it without per-screen plumbing. A shared, pure TimeFormat helper (formatTimeOfDay / formatMinuteOfDay / formatHourLabel / timeOfDayFormatter) is now the single source for all time-of-day rendering. Routing every site through it also fixes a pre-existing inconsistency: the week/day timeline gutters and the agenda screen + widget hard-coded 24h, while event detail/edit/search/reminders followed the locale — so a 12h-locale user previously saw mixed formats. Covered: week & day timelines (gutter + event blocks), agenda screen and widget, event detail/edit, search, and reminder notifications. Closes #6 (Codeberg) Co-Authored-By: Claude Opus 4.8 (1M context) --- .../jeanlucmakiola/calendula/MainActivity.kt | 32 ++++++++--- .../calendula/data/prefs/SettingsPrefs.kt | 26 +++++++++ .../data/reminders/EventReminderReceiver.kt | 2 +- .../data/reminders/ReminderNotifier.kt | 9 ++- .../data/reminders/ReminderTimeText.kt | 14 +++-- .../calendula/ui/agenda/AgendaScreen.kt | 11 +++- .../calendula/ui/common/TimeFormat.kt | 57 +++++++++++++++++++ .../calendula/ui/day/DayScreen.kt | 18 ++++-- .../calendula/ui/detail/EventDetailScreen.kt | 4 +- .../calendula/ui/edit/EventEditScreen.kt | 7 ++- .../calendula/ui/search/SearchScreen.kt | 7 ++- .../calendula/ui/settings/SettingsScreen.kt | 29 +++++++++- .../calendula/ui/settings/SettingsUiState.kt | 3 + .../ui/settings/SettingsViewModel.kt | 12 +++- .../calendula/ui/week/WeekScreen.kt | 16 ++++-- .../calendula/widget/WidgetData.kt | 20 +++++-- .../calendula/widget/agenda/AgendaWidget.kt | 15 ++--- app/src/main/res/values-de/strings.xml | 4 ++ app/src/main/res/values/strings.xml | 4 ++ .../data/reminders/ReminderTimeTextTest.kt | 18 ++++++ .../calendula/ui/common/TimeFormatTest.kt | 37 ++++++++++++ 21 files changed, 296 insertions(+), 49 deletions(-) create mode 100644 app/src/main/java/de/jeanlucmakiola/calendula/ui/common/TimeFormat.kt create mode 100644 app/src/test/java/de/jeanlucmakiola/calendula/ui/common/TimeFormatTest.kt diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/MainActivity.kt b/app/src/main/java/de/jeanlucmakiola/calendula/MainActivity.kt index cbacda4..bc79ac0 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/MainActivity.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/MainActivity.kt @@ -9,10 +9,13 @@ import androidx.activity.enableEdgeToEdge import androidx.appcompat.app.AppCompatActivity import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext import androidx.core.content.IntentCompat import androidx.core.net.toUri import androidx.hilt.navigation.compose.hiltViewModel @@ -20,7 +23,9 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle import dagger.hilt.android.AndroidEntryPoint import de.jeanlucmakiola.calendula.data.crash.CrashReporter import de.jeanlucmakiola.calendula.data.prefs.ThemeMode +import de.jeanlucmakiola.calendula.data.prefs.is24Hour import de.jeanlucmakiola.calendula.ui.RootScreen +import de.jeanlucmakiola.calendula.ui.common.LocalUse24HourFormat import de.jeanlucmakiola.calendula.ui.WidgetNavRequest import de.jeanlucmakiola.calendula.ui.common.CalendarView import de.jeanlucmakiola.calendula.ui.crash.CrashReportActivity @@ -81,19 +86,28 @@ class MainActivity : AppCompatActivity() { ThemeMode.LIGHT -> false ThemeMode.DARK -> true } + // The app-wide clock convention: the time-format preference resolved + // against the device's 24-hour system setting, provided once here so + // every time label reads it via LocalUse24HourFormat. + val context = LocalContext.current + val use24Hour = remember(settings.timeFormat, context) { + settings.timeFormat.is24Hour(android.text.format.DateFormat.is24HourFormat(context)) + } CalendulaTheme( darkTheme = darkTheme, dynamicColor = settings.dynamicColor, ) { - RootScreen( - modifier = Modifier.fillMaxSize(), - requestedDetailKey = requestedDetailKey, - onDetailKeyConsumed = { requestedDetailKey = null }, - widgetNavRequest = requestedNav, - onWidgetNavConsumed = { requestedNav = null }, - requestedImportUri = requestedImportUri, - onImportConsumed = { requestedImportUri = null }, - ) + CompositionLocalProvider(LocalUse24HourFormat provides use24Hour) { + RootScreen( + modifier = Modifier.fillMaxSize(), + requestedDetailKey = requestedDetailKey, + onDetailKeyConsumed = { requestedDetailKey = null }, + widgetNavRequest = requestedNav, + onWidgetNavConsumed = { requestedNav = null }, + requestedImportUri = requestedImportUri, + onImportConsumed = { requestedImportUri = null }, + ) + } pendingCrashReport?.let { report -> CrashReportDialog( report = report, 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 2b3cb82..24b12a7 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,22 @@ enum class ThemeMode { SYSTEM, LIGHT, DARK } /** Week-start override. AUTO derives the first day from the active locale. */ enum class WeekStartPref { AUTO, MONDAY, SUNDAY } +/** + * Clock convention for time-of-day labels. AUTO follows the device's 24-hour + * system setting; the others force a 12- or 24-hour clock app-wide. + */ +enum class TimeFormatPref { AUTO, TWELVE_HOUR, TWENTY_FOUR_HOUR } + +/** + * Resolve to a concrete 24-hour flag. AUTO defers to [systemIs24Hour] (the + * device's `DateFormat.is24HourFormat` value). + */ +fun TimeFormatPref.is24Hour(systemIs24Hour: Boolean): Boolean = when (this) { + TimeFormatPref.AUTO -> systemIs24Hour + TimeFormatPref.TWELVE_HOUR -> false + TimeFormatPref.TWENTY_FOUR_HOUR -> true +} + /** * Resolve the preference to a concrete first-day-of-week. AUTO reads the * locale's convention (e.g. Monday in DE, Sunday in en-US). @@ -70,6 +86,15 @@ class SettingsPrefs @Inject constructor( store.edit { it[WEEK_START_KEY] = pref.name } } + /** Clock convention for time-of-day labels (v2.11). Defaults to AUTO (system). */ + val timeFormat: Flow = store.data.map { prefs -> + prefs[TIME_FORMAT_KEY].toEnum(TimeFormatPref.AUTO) + } + + suspend fun setTimeFormat(pref: TimeFormatPref) { + store.edit { it[TIME_FORMAT_KEY] = pref.name } + } + /** * The calendar view the app opens on (M1). Defaults to [CalendarView.Week] — * the historical hard-coded startup view — so existing users see no change @@ -259,6 +284,7 @@ class SettingsPrefs @Inject constructor( internal val THEME_MODE_KEY = stringPreferencesKey("theme_mode") internal val DYNAMIC_COLOR_KEY = booleanPreferencesKey("dynamic_color") internal val WEEK_START_KEY = stringPreferencesKey("week_start") + internal val TIME_FORMAT_KEY = stringPreferencesKey("time_format") internal val DEFAULT_VIEW_KEY = stringPreferencesKey("default_view") internal val FORM_FIELDS_KEY = stringPreferencesKey("event_form_default_fields") internal val REMINDERS_ENABLED_KEY = booleanPreferencesKey("reminders_enabled") diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/data/reminders/EventReminderReceiver.kt b/app/src/main/java/de/jeanlucmakiola/calendula/data/reminders/EventReminderReceiver.kt index a5ed60c..71af6a3 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/data/reminders/EventReminderReceiver.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/data/reminders/EventReminderReceiver.kt @@ -46,7 +46,7 @@ class EventReminderReceiver : BroadcastReceiver() { if (settingsPrefs.remindersEnabled.first()) { val now = System.currentTimeMillis() val due = alertStore.dueAlerts(now) - due.forEach(notifier::post) + due.forEach { notifier.post(it) } alertStore.markFired(due.map { it.alertId }, now) } } finally { diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/data/reminders/ReminderNotifier.kt b/app/src/main/java/de/jeanlucmakiola/calendula/data/reminders/ReminderNotifier.kt index 358d9ca..7b92a9d 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/data/reminders/ReminderNotifier.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/data/reminders/ReminderNotifier.kt @@ -14,6 +14,9 @@ import androidx.core.content.ContextCompat import dagger.hilt.android.qualifiers.ApplicationContext import de.jeanlucmakiola.calendula.MainActivity import de.jeanlucmakiola.calendula.R +import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs +import de.jeanlucmakiola.calendula.data.prefs.is24Hour +import kotlinx.coroutines.flow.first import java.time.ZoneId import java.util.Locale import javax.inject.Inject @@ -29,6 +32,7 @@ import javax.inject.Singleton @Singleton class ReminderNotifier @Inject constructor( @ApplicationContext private val context: Context, + private val settingsPrefs: SettingsPrefs, ) { /** False when the user declined `POST_NOTIFICATIONS` or muted the app. */ @@ -39,15 +43,18 @@ class ReminderNotifier @Inject constructor( return granted && NotificationManagerCompat.from(context).areNotificationsEnabled() } - fun post(alert: ReminderAlert) { + suspend fun post(alert: ReminderAlert) { ensureChannel() val title = alert.title.ifBlank { context.getString(R.string.event_untitled) } + val is24Hour = settingsPrefs.timeFormat.first() + .is24Hour(android.text.format.DateFormat.is24HourFormat(context)) val time = reminderTimeText( beginMillis = alert.beginMillis, endMillis = alert.endMillis, isAllDay = alert.isAllDay, zone = ZoneId.systemDefault(), locale = Locale.getDefault(), + is24Hour = is24Hour, ) val text = listOfNotNull(time, alert.location).joinToString(" · ") val notification = NotificationCompat.Builder(context, CHANNEL_ID) diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/data/reminders/ReminderTimeText.kt b/app/src/main/java/de/jeanlucmakiola/calendula/data/reminders/ReminderTimeText.kt index 70b1354..ce7fd21 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/data/reminders/ReminderTimeText.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/data/reminders/ReminderTimeText.kt @@ -1,5 +1,6 @@ package de.jeanlucmakiola.calendula.data.reminders +import de.jeanlucmakiola.calendula.ui.common.timeOfDayFormatter import java.time.Instant import java.time.ZoneId import java.time.ZoneOffset @@ -25,6 +26,7 @@ fun reminderTimeText( isAllDay: Boolean, zone: ZoneId, locale: Locale, + is24Hour: Boolean, ): String { if (isAllDay) { val dateFormat = DateTimeFormatter.ofLocalizedDate(FormatStyle.MEDIUM).withLocale(locale) @@ -40,16 +42,18 @@ fun reminderTimeText( } } - val timeFormat = DateTimeFormatter.ofLocalizedTime(FormatStyle.SHORT).withLocale(locale) + val timeFormat = timeOfDayFormatter(is24Hour, locale) val begin = Instant.ofEpochMilli(beginMillis).atZone(zone) val end = Instant.ofEpochMilli(endMillis).atZone(zone) return if (begin.toLocalDate() == end.toLocalDate()) { timeFormat.format(begin) + RANGE + timeFormat.format(end) } else { - val dateTimeFormat = DateTimeFormatter - .ofLocalizedDateTime(FormatStyle.MEDIUM, FormatStyle.SHORT) - .withLocale(locale) - dateTimeFormat.format(begin) + RANGE + dateTimeFormat.format(end) + // Cross-day: medium date + the chosen short time, joined per side. Built + // from the two formatters (not ofLocalizedDateTime) so the 12/24h choice + // applies to the time portion too. + val dateFormat = DateTimeFormatter.ofLocalizedDate(FormatStyle.MEDIUM).withLocale(locale) + val dateTime = { z: java.time.ZonedDateTime -> "${dateFormat.format(z)}, ${timeFormat.format(z)}" } + dateTime(begin) + RANGE + dateTime(end) } } 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 ee72a1b..22e6f33 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 @@ -58,6 +58,9 @@ import de.jeanlucmakiola.calendula.ui.common.ViewSwitcherPill import de.jeanlucmakiola.calendula.ui.common.next import de.jeanlucmakiola.calendula.ui.common.pastelize import de.jeanlucmakiola.calendula.ui.common.positionOf +import de.jeanlucmakiola.calendula.ui.common.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 @@ -338,15 +341,17 @@ private fun agendaTimeSummary(event: EventInstance): String { val time = if (event.isAllDay) { stringResource(R.string.event_detail_all_day) } else { - "${formatTime(event.start)} – ${formatTime(event.end)}" + 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 } -private fun formatTime(instant: Instant): String { +private fun formatTime(instant: Instant, is24Hour: Boolean, locale: Locale): String { val t = instant.toLocalDateTime(zone).time - return "%02d:%02d".format(t.hour, t.minute) + return formatTimeOfDay(t.hour, t.minute, is24Hour, locale) } private fun formatAgendaDate(date: LocalDate): String { diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/TimeFormat.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/TimeFormat.kt new file mode 100644 index 0000000..362efb1 --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/TimeFormat.kt @@ -0,0 +1,57 @@ +package de.jeanlucmakiola.calendula.ui.common + +import androidx.compose.runtime.staticCompositionLocalOf +import java.time.LocalTime +import java.time.format.DateTimeFormatter +import java.util.Locale + +/** + * The resolved clock convention for the whole UI: `true` = 24-hour ("14:00"), + * `false` = 12-hour ("2:00 PM"). Provided once at the app root from the + * `TimeFormatPref` preference resolved against the device's 24-hour system + * setting, so every time label reads it without per-screen plumbing. Defaults + * to 24-hour for previews and any composition that forgets to provide it. + */ +val LocalUse24HourFormat = staticCompositionLocalOf { true } + +private const val PATTERN_24 = "HH:mm" +private const val PATTERN_12 = "h:mm a" +private const val HOUR_PATTERN_12 = "h a" + +/** A time-of-day [DateTimeFormatter] for the resolved convention and [locale]. */ +fun timeOfDayFormatter(is24Hour: Boolean, locale: Locale): DateTimeFormatter = + DateTimeFormatter.ofPattern(if (is24Hour) PATTERN_24 else PATTERN_12, locale) + +/** + * Format a wall-clock time (hour 0..23, minute 0..59) honouring the resolved + * [is24Hour] convention and [locale]: 24h → "14:00", 12h → "2:00 PM". Pure, so + * it can be unit-tested and used off the main thread (widget, notifications). + */ +fun formatTimeOfDay(hour: Int, minute: Int, is24Hour: Boolean, locale: Locale): String = + LocalTime.of(hour.coerceIn(0, 23), minute.coerceIn(0, 59)) + .format(timeOfDayFormatter(is24Hour, locale)) + +/** + * Format minutes-from-midnight (0..1440) as a time label. The end-of-day value + * 1440 renders as "24:00" in 24h (its established reading) and as midnight + * ("12:00 AM") in 12h, which has no 24:00 equivalent. + */ +fun formatMinuteOfDay(minutes: Int, is24Hour: Boolean, locale: Locale): String = when { + minutes >= MINUTES_PER_DAY && is24Hour -> "24:00" + minutes >= MINUTES_PER_DAY -> formatTimeOfDay(0, 0, is24Hour = false, locale) + else -> formatTimeOfDay(minutes / 60, minutes % 60, is24Hour, locale) +} + +/** + * The compact hour-only label for a timeline gutter: 24h → "13" (zero-padded, + * the prior look); 12h → "1 PM". + */ +fun formatHourLabel(hour: Int, is24Hour: Boolean, locale: Locale): String = + if (is24Hour) { + "%02d".format(hour) + } else { + LocalTime.of(hour.coerceIn(0, 23), 0) + .format(DateTimeFormatter.ofPattern(HOUR_PATTERN_12, locale)) + } + +private const val MINUTES_PER_DAY = 1_440 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 6354122..2d7e0ae 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 @@ -80,7 +80,10 @@ import de.jeanlucmakiola.calendula.ui.common.rememberReduceMotion import de.jeanlucmakiola.calendula.ui.common.next import de.jeanlucmakiola.calendula.ui.common.pastelize import de.jeanlucmakiola.calendula.ui.common.rememberCalendarSlideSpec -import de.jeanlucmakiola.calendula.ui.week.MINUTES_PER_DAY +import de.jeanlucmakiola.calendula.ui.common.currentLocale +import de.jeanlucmakiola.calendula.ui.common.LocalUse24HourFormat +import de.jeanlucmakiola.calendula.ui.common.formatHourLabel +import de.jeanlucmakiola.calendula.ui.common.formatMinuteOfDay import de.jeanlucmakiola.calendula.ui.week.TimedBlock import kotlinx.coroutines.flow.first import kotlinx.coroutines.launch @@ -466,6 +469,8 @@ private fun Timeline( ) { val totalHeight = HOUR_HEIGHT * 24 val dark = isSystemInDarkTheme() + val use24Hour = LocalUse24HourFormat.current + val locale = currentLocale() Box(modifier = Modifier.fillMaxSize()) { // Gutter and day column are two scroll viewports that SHARE one scroll @@ -488,7 +493,7 @@ private fun Timeline( ) { if (h > 0) { Text( - text = "%02d".format(h), + text = formatHourLabel(h, use24Hour, locale), style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier @@ -589,7 +594,10 @@ private fun EventBlock( modifier: Modifier = Modifier, ) { val title = block.event.title.ifBlank { stringResource(R.string.event_untitled) } - val timeLabel = "${minToHm(block.startMin)}–${minToHm(block.endMin)}" + val use24Hour = LocalUse24HourFormat.current + val locale = currentLocale() + val timeLabel = "${minToHm(block.startMin, use24Hour, locale)}–" + + minToHm(block.endMin, use24Hour, locale) val showTime = block.endMin - block.startMin >= 45 Box( modifier = modifier @@ -635,8 +643,8 @@ private fun DayLoading() { } } -private fun minToHm(min: Int): String = - if (min >= MINUTES_PER_DAY) "24:00" else "%02d:%02d".format(min / 60, min % 60) +private fun minToHm(min: Int, is24Hour: Boolean, locale: Locale): String = + formatMinuteOfDay(min, is24Hour, locale) private fun formatDayTitle(date: LocalDate): String { val locale = Locale.getDefault() 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 35dc26b..c187ac3 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 @@ -95,6 +95,8 @@ import de.jeanlucmakiola.calendula.ui.common.predictiveBack import de.jeanlucmakiola.calendula.ui.common.CalendarFailure import de.jeanlucmakiola.calendula.ui.common.OptionCard import de.jeanlucmakiola.calendula.ui.common.currentLocale +import de.jeanlucmakiola.calendula.ui.common.LocalUse24HourFormat +import de.jeanlucmakiola.calendula.ui.common.timeOfDayFormatter import de.jeanlucmakiola.calendula.ui.common.pastelize import de.jeanlucmakiola.calendula.ui.common.recurrenceText import de.jeanlucmakiola.calendula.ui.common.reminderLeadTimeLabel @@ -781,7 +783,7 @@ private fun formatWhen( val zid = ZoneId.of(zone.id) val dateFull = DateTimeFormatter.ofLocalizedDate(FormatStyle.FULL).withLocale(locale) val dateMedium = DateTimeFormatter.ofLocalizedDate(FormatStyle.MEDIUM).withLocale(locale) - val timeShort = DateTimeFormatter.ofLocalizedTime(FormatStyle.SHORT).withLocale(locale) + val timeShort = timeOfDayFormatter(LocalUse24HourFormat.current, locale) val startLdt = instance.start.toJavaLocalDateTime(zid) val allDayLabel = stringResource(R.string.event_detail_all_day) 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 b29a75d..853edfe 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 @@ -132,6 +132,8 @@ import de.jeanlucmakiola.calendula.ui.common.REMINDER_PRESETS import de.jeanlucmakiola.calendula.ui.common.ReminderUnit import de.jeanlucmakiola.calendula.ui.common.TimePickerAlert import de.jeanlucmakiola.calendula.ui.common.currentLocale +import de.jeanlucmakiola.calendula.ui.common.LocalUse24HourFormat +import de.jeanlucmakiola.calendula.ui.common.timeOfDayFormatter import de.jeanlucmakiola.calendula.ui.common.reminderLeadTimeLabel import de.jeanlucmakiola.calendula.ui.common.reminderUnitLabel import de.jeanlucmakiola.calendula.ui.common.pastelize @@ -1891,8 +1893,9 @@ private fun ScheduleRow( val dateFormat = remember(locale) { DateTimeFormatter.ofLocalizedDate(FormatStyle.MEDIUM).withLocale(locale) } - val timeFormat = remember(locale) { - DateTimeFormatter.ofLocalizedTime(FormatStyle.SHORT).withLocale(locale) + val use24Hour = LocalUse24HourFormat.current + val timeFormat = remember(locale, use24Hour) { + timeOfDayFormatter(use24Hour, locale) } // Tappable values read as links (primary), like the location on the // detail screen; errors flip them to the error colour. 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 17d467f..c015173 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,6 +54,8 @@ import de.jeanlucmakiola.calendula.ui.common.GroupedRow import de.jeanlucmakiola.calendula.ui.common.InlineTextField import de.jeanlucmakiola.calendula.ui.common.Position import de.jeanlucmakiola.calendula.ui.common.currentLocale +import de.jeanlucmakiola.calendula.ui.common.LocalUse24HourFormat +import de.jeanlucmakiola.calendula.ui.common.timeOfDayFormatter import de.jeanlucmakiola.calendula.ui.common.pastelize import de.jeanlucmakiola.calendula.ui.common.positionOf import java.time.Instant as JavaInstant @@ -217,12 +219,11 @@ private fun searchSummary(event: EventInstance): String { val dateText = remember(locale) { DateTimeFormatter.ofLocalizedDate(FormatStyle.MEDIUM).withLocale(locale) }.format(start) + val use24Hour = LocalUse24HourFormat.current val timeText = if (event.isAllDay) { stringResource(R.string.event_detail_all_day) } else { - remember(locale) { - DateTimeFormatter.ofLocalizedTime(FormatStyle.SHORT).withLocale(locale) - }.format(start) + remember(locale, use24Hour) { timeOfDayFormatter(use24Hour, locale) }.format(start) } val base = "$dateText · $timeText" return event.location?.takeIf { it.isNotBlank() }?.let { "$base · $it" } ?: base 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 21a8f7a..bf988fd 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 @@ -84,6 +84,7 @@ import de.jeanlucmakiola.calendula.R import de.jeanlucmakiola.calendula.data.crash.CrashReporter import de.jeanlucmakiola.calendula.data.prefs.CalendarReminderOverride 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.domain.EventFormField import de.jeanlucmakiola.calendula.qs.NewEventTileService @@ -424,6 +425,7 @@ private fun AppearanceScreen( ) { var showTheme by remember { mutableStateOf(false) } var showWeekStart by remember { mutableStateOf(false) } + var showTimeFormat by remember { mutableStateOf(false) } var showDefaultView by remember { mutableStateOf(false) } CollapsingScaffold( @@ -466,9 +468,15 @@ private fun AppearanceScreen( GroupedRow( title = stringResource(R.string.settings_week_start), summary = weekStartLabel(state.weekStart), - position = Position.Bottom, + position = Position.Middle, onClick = { showWeekStart = true }, ) + GroupedRow( + title = stringResource(R.string.settings_time_format), + summary = timeFormatLabel(state.timeFormat), + position = Position.Bottom, + onClick = { showTimeFormat = true }, + ) } if (showTheme) { @@ -491,6 +499,16 @@ private fun AppearanceScreen( onDismiss = { showWeekStart = false }, ) } + if (showTimeFormat) { + OptionPicker( + title = stringResource(R.string.settings_time_format), + options = TimeFormatPref.entries, + selected = state.timeFormat, + label = { timeFormatLabel(it) }, + onSelect = viewModel::setTimeFormat, + onDismiss = { showTimeFormat = false }, + ) + } if (showDefaultView) { OptionPicker( title = stringResource(R.string.settings_default_view), @@ -1002,6 +1020,15 @@ private fun weekStartLabel(pref: WeekStartPref): String = stringResource( }, ) +@Composable +private fun timeFormatLabel(pref: TimeFormatPref): String = stringResource( + when (pref) { + TimeFormatPref.AUTO -> R.string.settings_time_format_auto + TimeFormatPref.TWELVE_HOUR -> R.string.settings_time_format_12h + TimeFormatPref.TWENTY_FOUR_HOUR -> R.string.settings_time_format_24h + }, +) + @Composable private fun languageLabel(tag: String?): String = if (tag == null) stringResource(R.string.settings_language_auto) else AppLanguage.displayName(tag) 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 1dac3bb..818c509 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 @@ -2,6 +2,7 @@ package de.jeanlucmakiola.calendula.ui.settings import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs 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.domain.CalendarSource import de.jeanlucmakiola.calendula.domain.EventFormField @@ -18,6 +19,8 @@ data class SettingsUiState( val dynamicColor: Boolean = true, val dynamicColorAvailable: Boolean = true, val weekStart: WeekStartPref = WeekStartPref.AUTO, + /** Clock convention for time labels (v2.11). AUTO follows the system setting. */ + val timeFormat: TimeFormatPref = TimeFormatPref.AUTO, /** The calendar view the app opens on, and the home of the view back stack (M1). */ val defaultView: CalendarView = CalendarView.Week, /** 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 45a3160..eea8c5c 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 @@ -8,6 +8,7 @@ import de.jeanlucmakiola.calendula.data.calendar.CalendarRepository import de.jeanlucmakiola.calendula.data.prefs.CalendarReminderOverride import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs 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.domain.CalendarSource import de.jeanlucmakiola.calendula.domain.EventFormField @@ -73,10 +74,11 @@ class SettingsViewModel @Inject constructor( ) { overrides, allDayOverrides, calendars -> ReminderOverrides(overrides, allDayOverrides, calendars) }, - prefs.defaultView, - ) { base, defaults, overrides, defaultView -> + combine(prefs.defaultView, prefs.timeFormat) { view, timeFormat -> view to timeFormat }, + ) { base, defaults, overrides, viewAndTimeFormat -> base.copy( - defaultView = defaultView, + defaultView = viewAndTimeFormat.first, + timeFormat = viewAndTimeFormat.second, allowColorOnUnsupportedCalendars = defaults.allowColor, defaultReminderMinutes = defaults.defaultReminder, defaultAllDayReminderMinutes = defaults.allDayReminder, @@ -118,6 +120,10 @@ class SettingsViewModel @Inject constructor( viewModelScope.launch { prefs.setWeekStart(pref) } } + fun setTimeFormat(pref: TimeFormatPref) { + viewModelScope.launch { prefs.setTimeFormat(pref) } + } + fun setDefaultView(view: CalendarView) { viewModelScope.launch { prefs.setDefaultView(view) } } 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 8d9a311..1af83fa 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 @@ -83,6 +83,9 @@ import de.jeanlucmakiola.calendula.ui.common.calendarSlideTransition import de.jeanlucmakiola.calendula.ui.common.rememberCalendarFadeSpec import de.jeanlucmakiola.calendula.ui.common.rememberReduceMotion import de.jeanlucmakiola.calendula.ui.common.currentLocale +import de.jeanlucmakiola.calendula.ui.common.LocalUse24HourFormat +import de.jeanlucmakiola.calendula.ui.common.formatHourLabel +import de.jeanlucmakiola.calendula.ui.common.formatMinuteOfDay import de.jeanlucmakiola.calendula.ui.common.rememberCalendarSlideSpec import de.jeanlucmakiola.calendula.ui.common.next import de.jeanlucmakiola.calendula.ui.common.pastelize @@ -572,6 +575,8 @@ private fun Timeline( ) { val totalHeight = HOUR_HEIGHT * 24 val dark = isSystemInDarkTheme() + val use24Hour = LocalUse24HourFormat.current + val locale = currentLocale() Box(modifier = Modifier.fillMaxSize()) { // Gutter and day columns are two scroll viewports that SHARE one scroll @@ -595,7 +600,7 @@ private fun Timeline( ) { if (h > 0) { Text( - text = "%02d".format(h), + text = formatHourLabel(h, use24Hour, locale), style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier @@ -704,7 +709,10 @@ private fun EventBlock( modifier: Modifier = Modifier, ) { val title = block.event.title.ifBlank { stringResource(R.string.event_untitled) } - val timeLabel = "${minToHm(block.startMin)}–${minToHm(block.endMin)}" + val use24Hour = LocalUse24HourFormat.current + val locale = currentLocale() + val timeLabel = "${minToHm(block.startMin, use24Hour, locale)}–" + + minToHm(block.endMin, use24Hour, locale) val showTime = block.endMin - block.startMin >= 45 Box( modifier = modifier @@ -773,8 +781,8 @@ private fun WeekLoading() { } } -private fun minToHm(min: Int): String = - if (min >= MINUTES_PER_DAY) "24:00" else "%02d:%02d".format(min / 60, min % 60) +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() 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 854a5e0..e8870fe 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/widget/WidgetData.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/widget/WidgetData.kt @@ -4,6 +4,7 @@ import android.Manifest import android.content.Context import android.content.pm.PackageManager import androidx.core.content.ContextCompat +import de.jeanlucmakiola.calendula.data.prefs.is24Hour import de.jeanlucmakiola.calendula.data.prefs.resolveFirstDay import de.jeanlucmakiola.calendula.domain.EventInstance import de.jeanlucmakiola.calendula.ui.agenda.AgendaDay @@ -46,7 +47,12 @@ internal fun Context.hasCalendarPermission(): Boolean = sealed interface AgendaWidgetData { /** Calendar permission not granted — the widget can't read events. */ data object NeedsPermission : AgendaWidgetData - data class Ready(val today: LocalDate, val days: List) : AgendaWidgetData + data class Ready( + val today: LocalDate, + val days: List, + /** Resolved clock convention for event time labels (the time-format pref). */ + val is24Hour: Boolean, + ) : AgendaWidgetData } /** @@ -91,9 +97,15 @@ internal suspend fun Context.loadAgendaWidgetData(): AgendaWidgetData { if (!hasCalendarPermission()) return AgendaWidgetData.NeedsPermission val zone = systemZone() val anchor = today(zone) - val repo = widgetEntryPoint().calendarRepository() - val instances = repo.instances(agendaRange(anchor, AGENDA_WIDGET_DAYS, zone)).first() - return AgendaWidgetData.Ready(today = anchor, days = groupAgendaDays(anchor, instances, zone)) + val ep = widgetEntryPoint() + val instances = ep.calendarRepository().instances(agendaRange(anchor, AGENDA_WIDGET_DAYS, zone)).first() + val is24Hour = ep.settingsPrefs().timeFormat.first() + .is24Hour(android.text.format.DateFormat.is24HourFormat(this)) + return AgendaWidgetData.Ready( + today = anchor, + days = groupAgendaDays(anchor, instances, zone), + is24Hour = is24Hour, + ) } /** One-shot wide read backing the month widget's grid for any nearby month. */ 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 036ac13..a2a28c3 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 @@ -42,6 +42,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.formatTimeOfDay import de.jeanlucmakiola.calendula.ui.common.pastelize import de.jeanlucmakiola.calendula.widget.AgendaWidgetData import de.jeanlucmakiola.calendula.widget.CalendulaGlanceTheme @@ -115,7 +116,7 @@ private fun AgendaWidgetBody(data: AgendaWidgetData, dark: Boolean) { items(rows.size) { index -> when (val row = rows[index]) { is AgendaRow.Header -> DayHeaderRow(row.date, row.today) - is AgendaRow.Event -> EventRow(row.event, dark) + is AgendaRow.Event -> EventRow(row.event, dark, data.is24Hour) } } } @@ -195,7 +196,7 @@ private fun DayHeaderRow(date: LocalDate, today: LocalDate) { } @Composable -private fun EventRow(event: EventInstance, dark: Boolean) { +private fun EventRow(event: EventInstance, dark: Boolean, is24Hour: Boolean) { val context = androidx.glance.LocalContext.current val title = event.title.ifBlank { context.getString(R.string.event_untitled) } Row( @@ -230,7 +231,7 @@ private fun EventRow(event: EventInstance, dark: Boolean) { style = TextStyle(color = GlanceTheme.colors.onSurface, fontSize = 14.sp), ) Text( - text = eventTimeSummary(context, event), + text = eventTimeSummary(context, event, is24Hour), maxLines = 1, style = TextStyle(color = GlanceTheme.colors.onSurfaceVariant, fontSize = 12.sp), ) @@ -269,17 +270,17 @@ private fun agendaDayLabel(context: Context, date: LocalDate, today: LocalDate): return if (relative != null) "$relative · $formatted" else formatted } -private fun eventTimeSummary(context: Context, event: EventInstance): String { +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)} – ${formatTime(event.end)}" + "${formatTime(event.start, is24Hour)} – ${formatTime(event.end, is24Hour)}" } val location = event.location?.takeIf { it.isNotBlank() } return if (location != null) "$time · $location" else time } -private fun formatTime(instant: Instant): String { +private fun formatTime(instant: Instant, is24Hour: Boolean): String { val t = instant.toLocalDateTime(zone()).time - return "%02d:%02d".format(t.hour, t.minute) + return formatTimeOfDay(t.hour, t.minute, is24Hour, Locale.getDefault()) } diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index aa24a30..1e5a40d 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -278,6 +278,10 @@ Automatisch Montag Sonntag + Zeitformat + Automatisch + 12-Stunden (2:00 PM) + 24-Stunden (14:00) Termin-Formular Standardmäßig angezeigte Felder — alles Weitere liegt hinter \"Weitere Felder\" Farben auf nicht unterstützten Kalendern erlauben diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 644286f..5c0a913 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -271,6 +271,10 @@ Automatic Monday Sunday + Time format + Automatic + 12-hour (2:00 PM) + 24-hour (14:00) New event form Fields shown by default — everything else sits behind \"More fields\" Allow colors on unsupported calendars diff --git a/app/src/test/java/de/jeanlucmakiola/calendula/data/reminders/ReminderTimeTextTest.kt b/app/src/test/java/de/jeanlucmakiola/calendula/data/reminders/ReminderTimeTextTest.kt index e17fa80..4d3f6c6 100644 --- a/app/src/test/java/de/jeanlucmakiola/calendula/data/reminders/ReminderTimeTextTest.kt +++ b/app/src/test/java/de/jeanlucmakiola/calendula/data/reminders/ReminderTimeTextTest.kt @@ -26,10 +26,24 @@ class ReminderTimeTextTest { isAllDay = false, zone = berlin, locale = Locale.GERMANY, + is24Hour = true, ) assertThat(text).isEqualTo("09:30 – 10:00") } + @Test + fun `12-hour preference renders an am-pm time range`() { + val text = reminderTimeText( + beginMillis = millisAt(LocalDateTime.of(2026, 6, 11, 14, 0), berlin), + endMillis = millisAt(LocalDateTime.of(2026, 6, 11, 15, 0), berlin), + isAllDay = false, + zone = berlin, + locale = Locale.US, + is24Hour = false, + ) + assertThat(text).isEqualTo("2:00 PM – 3:00 PM") + } + @Test fun `timed event crossing midnight includes both dates`() { val text = reminderTimeText( @@ -38,6 +52,7 @@ class ReminderTimeTextTest { isAllDay = false, zone = berlin, locale = Locale.GERMANY, + is24Hour = true, ) assertThat(text).contains("11.06.2026") assertThat(text).contains("12.06.2026") @@ -54,6 +69,7 @@ class ReminderTimeTextTest { // 02:00 in Berlin — naive local reading would shift the day. zone = berlin, locale = Locale.GERMANY, + is24Hour = true, ) assertThat(text).isEqualTo("11.06.2026") } @@ -66,6 +82,7 @@ class ReminderTimeTextTest { isAllDay = true, zone = berlin, locale = Locale.GERMANY, + is24Hour = true, ) assertThat(text).isEqualTo("11.06.2026 – 12.06.2026") } @@ -79,6 +96,7 @@ class ReminderTimeTextTest { isAllDay = true, zone = berlin, locale = Locale.GERMANY, + is24Hour = true, ) assertThat(text).isEqualTo("11.06.2026") } diff --git a/app/src/test/java/de/jeanlucmakiola/calendula/ui/common/TimeFormatTest.kt b/app/src/test/java/de/jeanlucmakiola/calendula/ui/common/TimeFormatTest.kt new file mode 100644 index 0000000..4aca580 --- /dev/null +++ b/app/src/test/java/de/jeanlucmakiola/calendula/ui/common/TimeFormatTest.kt @@ -0,0 +1,37 @@ +package de.jeanlucmakiola.calendula.ui.common + +import com.google.common.truth.Truth.assertThat +import org.junit.jupiter.api.Test +import java.util.Locale + +class TimeFormatTest { + + @Test + fun `24-hour format is zero-padded HH mm`() { + assertThat(formatTimeOfDay(9, 5, is24Hour = true, Locale.US)).isEqualTo("09:05") + assertThat(formatTimeOfDay(14, 0, is24Hour = true, Locale.US)).isEqualTo("14:00") + assertThat(formatTimeOfDay(0, 0, is24Hour = true, Locale.US)).isEqualTo("00:00") + } + + @Test + fun `12-hour format adds a localised am-pm marker`() { + assertThat(formatTimeOfDay(14, 0, is24Hour = false, Locale.US)).isEqualTo("2:00 PM") + assertThat(formatTimeOfDay(0, 30, is24Hour = false, Locale.US)).isEqualTo("12:30 AM") + assertThat(formatTimeOfDay(12, 0, is24Hour = false, Locale.US)).isEqualTo("12:00 PM") + } + + @Test + fun `minute-of-day end-of-day renders 24 00 in 24h and midnight in 12h`() { + assertThat(formatMinuteOfDay(1_440, is24Hour = true, Locale.US)).isEqualTo("24:00") + assertThat(formatMinuteOfDay(1_440, is24Hour = false, Locale.US)).isEqualTo("12:00 AM") + assertThat(formatMinuteOfDay(13 * 60 + 15, is24Hour = true, Locale.US)).isEqualTo("13:15") + } + + @Test + fun `hour label is zero-padded in 24h and compact am-pm in 12h`() { + assertThat(formatHourLabel(13, is24Hour = true, Locale.US)).isEqualTo("13") + assertThat(formatHourLabel(13, is24Hour = false, Locale.US)).isEqualTo("1 PM") + assertThat(formatHourLabel(1, is24Hour = false, Locale.US)).isEqualTo("1 AM") + assertThat(formatHourLabel(0, is24Hour = false, Locale.US)).isEqualTo("12 AM") + } +} From 09a1aecb764a2d7a1b41e6ebf3b6caf5e8555dbf Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Sat, 27 Jun 2026 14:07:09 +0200 Subject: [PATCH 03/26] feat(timeline): optional hour separator lines in week & day view MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a showHourLines preference (default off — the existing clean look). When on, the week and day timelines draw a faint outline-variant line at each hour boundary, sitting over the column background but beneath event blocks. The toggle is provided app-wide via LocalShowHourLines and applied through a reusable hourSeparatorLines() modifier. Closes #5 (Codeberg) Co-Authored-By: Claude Opus 4.8 (1M context) --- .../jeanlucmakiola/calendula/MainActivity.kt | 6 ++- .../calendula/data/prefs/SettingsPrefs.kt | 13 +++++++ .../calendula/ui/common/HourLines.kt | 38 +++++++++++++++++++ .../calendula/ui/day/DayScreen.kt | 7 ++++ .../calendula/ui/settings/SettingsScreen.kt | 14 ++++++- .../calendula/ui/settings/SettingsUiState.kt | 2 + .../ui/settings/SettingsViewModel.kt | 8 +++- .../calendula/ui/week/WeekScreen.kt | 7 ++++ app/src/main/res/values-de/strings.xml | 2 + app/src/main/res/values/strings.xml | 2 + .../calendula/data/prefs/SettingsPrefsTest.kt | 16 ++++++++ 11 files changed, 112 insertions(+), 3 deletions(-) create mode 100644 app/src/main/java/de/jeanlucmakiola/calendula/ui/common/HourLines.kt diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/MainActivity.kt b/app/src/main/java/de/jeanlucmakiola/calendula/MainActivity.kt index bc79ac0..840f09b 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/MainActivity.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/MainActivity.kt @@ -25,6 +25,7 @@ import de.jeanlucmakiola.calendula.data.crash.CrashReporter import de.jeanlucmakiola.calendula.data.prefs.ThemeMode import de.jeanlucmakiola.calendula.data.prefs.is24Hour import de.jeanlucmakiola.calendula.ui.RootScreen +import de.jeanlucmakiola.calendula.ui.common.LocalShowHourLines import de.jeanlucmakiola.calendula.ui.common.LocalUse24HourFormat import de.jeanlucmakiola.calendula.ui.WidgetNavRequest import de.jeanlucmakiola.calendula.ui.common.CalendarView @@ -97,7 +98,10 @@ class MainActivity : AppCompatActivity() { darkTheme = darkTheme, dynamicColor = settings.dynamicColor, ) { - CompositionLocalProvider(LocalUse24HourFormat provides use24Hour) { + CompositionLocalProvider( + LocalUse24HourFormat provides use24Hour, + LocalShowHourLines provides settings.showHourLines, + ) { RootScreen( modifier = Modifier.fillMaxSize(), requestedDetailKey = requestedDetailKey, 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 24b12a7..bbb4b25 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 @@ -95,6 +95,18 @@ class SettingsPrefs @Inject constructor( store.edit { it[TIME_FORMAT_KEY] = pref.name } } + /** + * Whether the week/day timeline draws a faint separator line at each hour + * (v2.11). Defaults to OFF — the historical clean look; users opt in. + */ + val showHourLines: Flow = store.data.map { prefs -> + prefs[SHOW_HOUR_LINES_KEY] ?: false + } + + suspend fun setShowHourLines(enabled: Boolean) { + store.edit { it[SHOW_HOUR_LINES_KEY] = enabled } + } + /** * The calendar view the app opens on (M1). Defaults to [CalendarView.Week] — * the historical hard-coded startup view — so existing users see no change @@ -285,6 +297,7 @@ class SettingsPrefs @Inject constructor( internal val DYNAMIC_COLOR_KEY = booleanPreferencesKey("dynamic_color") internal val WEEK_START_KEY = stringPreferencesKey("week_start") internal val TIME_FORMAT_KEY = stringPreferencesKey("time_format") + internal val SHOW_HOUR_LINES_KEY = booleanPreferencesKey("show_hour_lines") internal val DEFAULT_VIEW_KEY = stringPreferencesKey("default_view") internal val FORM_FIELDS_KEY = stringPreferencesKey("event_form_default_fields") internal val REMINDERS_ENABLED_KEY = booleanPreferencesKey("reminders_enabled") diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/HourLines.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/HourLines.kt new file mode 100644 index 0000000..6ae2d71 --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/HourLines.kt @@ -0,0 +1,38 @@ +package de.jeanlucmakiola.calendula.ui.common + +import androidx.compose.runtime.staticCompositionLocalOf +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.drawBehind +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Color + +/** + * Whether the week/day timeline draws an hour separator line, from the + * `showHourLines` preference. Provided once at the app root (like + * [LocalUse24HourFormat]) so the timeline reads it without ViewModel plumbing. + * Defaults to off — the historical clean look. + */ +val LocalShowHourLines = staticCompositionLocalOf { false } + +/** + * Draw a faint separator line at the top of each hour (1..23) when [show] is + * true. Applied to a day column's content so each line sits over the column's + * background but beneath the event blocks. [hourHeightPx] is one hour's pixel + * height; [color] is resolved by the caller from the theme. + */ +fun Modifier.hourSeparatorLines(show: Boolean, hourHeightPx: Float, color: Color): Modifier = + if (!show) { + this + } else { + drawBehind { + for (hour in 1 until 24) { + val y = hour * hourHeightPx + drawLine( + color = color, + start = Offset(0f, y), + end = Offset(size.width, y), + strokeWidth = 1f, + ) + } + } + } 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 2d7e0ae..97cbdce 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 @@ -82,8 +82,10 @@ import de.jeanlucmakiola.calendula.ui.common.pastelize import de.jeanlucmakiola.calendula.ui.common.rememberCalendarSlideSpec import de.jeanlucmakiola.calendula.ui.common.currentLocale import de.jeanlucmakiola.calendula.ui.common.LocalUse24HourFormat +import de.jeanlucmakiola.calendula.ui.common.LocalShowHourLines import de.jeanlucmakiola.calendula.ui.common.formatHourLabel import de.jeanlucmakiola.calendula.ui.common.formatMinuteOfDay +import de.jeanlucmakiola.calendula.ui.common.hourSeparatorLines import de.jeanlucmakiola.calendula.ui.week.TimedBlock import kotlinx.coroutines.flow.first import kotlinx.coroutines.launch @@ -539,6 +541,8 @@ private fun DayColumnCard( modifier: Modifier = Modifier, ) { val hourPx = with(LocalDensity.current) { HOUR_HEIGHT.toPx() } + val showHourLines = LocalShowHourLines.current + val hourLineColor = MaterialTheme.colorScheme.outlineVariant Card( // Plain rectangular column — the soft corners come from the outer // rounded scroll viewport, so inner rounding would look odd at the edges. @@ -551,6 +555,9 @@ private fun DayColumnCard( BoxWithConstraints( modifier = Modifier .fillMaxSize() + // Faint hour separators sit over the column background but under + // the event blocks (drawBehind paints before the children). + .hourSeparatorLines(showHourLines, hourPx, hourLineColor) // Tap an empty slot to create an event there. Taps on event // blocks are consumed by their own click handler first, so this // only fires on the column background. Snaps to the tapped hour. 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 bf988fd..2db542f 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 @@ -474,9 +474,21 @@ private fun AppearanceScreen( GroupedRow( title = stringResource(R.string.settings_time_format), summary = timeFormatLabel(state.timeFormat), - position = Position.Bottom, + position = Position.Middle, onClick = { showTimeFormat = true }, ) + GroupedRow( + title = stringResource(R.string.settings_hour_lines), + summary = stringResource(R.string.settings_hour_lines_summary), + position = Position.Bottom, + trailing = { + Switch( + checked = state.showHourLines, + onCheckedChange = viewModel::setShowHourLines, + ) + }, + onClick = { viewModel.setShowHourLines(!state.showHourLines) }, + ) } if (showTheme) { 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 818c509..1c97524 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 @@ -21,6 +21,8 @@ data class SettingsUiState( val weekStart: WeekStartPref = WeekStartPref.AUTO, /** Clock convention for time labels (v2.11). AUTO follows the system setting. */ val timeFormat: TimeFormatPref = TimeFormatPref.AUTO, + /** Whether the week/day timeline draws an hour separator line (v2.11). */ + val showHourLines: Boolean = false, /** The calendar view the app opens on, and the home of the view back stack (M1). */ val defaultView: CalendarView = CalendarView.Week, /** 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 eea8c5c..e452eac 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 @@ -75,10 +75,12 @@ class SettingsViewModel @Inject constructor( ReminderOverrides(overrides, allDayOverrides, calendars) }, combine(prefs.defaultView, prefs.timeFormat) { view, timeFormat -> view to timeFormat }, - ) { base, defaults, overrides, viewAndTimeFormat -> + prefs.showHourLines, + ) { base, defaults, overrides, viewAndTimeFormat, showHourLines -> base.copy( defaultView = viewAndTimeFormat.first, timeFormat = viewAndTimeFormat.second, + showHourLines = showHourLines, allowColorOnUnsupportedCalendars = defaults.allowColor, defaultReminderMinutes = defaults.defaultReminder, defaultAllDayReminderMinutes = defaults.allDayReminder, @@ -124,6 +126,10 @@ class SettingsViewModel @Inject constructor( viewModelScope.launch { prefs.setTimeFormat(pref) } } + fun setShowHourLines(enabled: Boolean) { + viewModelScope.launch { prefs.setShowHourLines(enabled) } + } + fun setDefaultView(view: CalendarView) { viewModelScope.launch { prefs.setDefaultView(view) } } 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 1af83fa..63bbe74 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 @@ -84,8 +84,10 @@ import de.jeanlucmakiola.calendula.ui.common.rememberCalendarFadeSpec import de.jeanlucmakiola.calendula.ui.common.rememberReduceMotion import de.jeanlucmakiola.calendula.ui.common.currentLocale import de.jeanlucmakiola.calendula.ui.common.LocalUse24HourFormat +import de.jeanlucmakiola.calendula.ui.common.LocalShowHourLines import de.jeanlucmakiola.calendula.ui.common.formatHourLabel import de.jeanlucmakiola.calendula.ui.common.formatMinuteOfDay +import de.jeanlucmakiola.calendula.ui.common.hourSeparatorLines import de.jeanlucmakiola.calendula.ui.common.rememberCalendarSlideSpec import de.jeanlucmakiola.calendula.ui.common.next import de.jeanlucmakiola.calendula.ui.common.pastelize @@ -655,6 +657,8 @@ private fun DayColumnCard( modifier: Modifier = Modifier, ) { val hourPx = with(LocalDensity.current) { HOUR_HEIGHT.toPx() } + val showHourLines = LocalShowHourLines.current + val hourLineColor = MaterialTheme.colorScheme.outlineVariant Card( // Plain rectangular columns — the soft corners come from the outer // rounded scroll viewport, so inner rounding would look odd at the edges. @@ -667,6 +671,9 @@ private fun DayColumnCard( BoxWithConstraints( modifier = Modifier .fillMaxSize() + // Faint hour separators sit over the column background but under + // the event blocks (drawBehind paints before the children). + .hourSeparatorLines(showHourLines, hourPx, hourLineColor) // Tap an empty slot to create an event there; taps on event // blocks are consumed by their own handler first. Snaps to hour. .pointerInput(date) { diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index 1e5a40d..8111c2e 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -282,6 +282,8 @@ Automatisch 12-Stunden (2:00 PM) 24-Stunden (14:00) + Stundenlinien + Trennlinie zu jeder vollen Stunde in Wochen- und Tagesansicht anzeigen Termin-Formular Standardmäßig angezeigte Felder — alles Weitere liegt hinter \"Weitere Felder\" Farben auf nicht unterstützten Kalendern erlauben diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 5c0a913..fc49f46 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -275,6 +275,8 @@ Automatic 12-hour (2:00 PM) 24-hour (14:00) + Hour lines + Show a separator line at each hour in week and day view New event form Fields shown by default — everything else sits behind \"More fields\" Allow colors on unsupported calendars 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 dee5554..bfaa05f 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 @@ -49,6 +49,22 @@ class SettingsPrefsTest { assertThat(prefs.weekStart.first()).isEqualTo(WeekStartPref.SUNDAY) } + @Test + fun `time format defaults to auto and round-trips`(@TempDir tempDir: Path) = runTest { + val prefs = SettingsPrefs(newDataStore(tempDir)) + assertThat(prefs.timeFormat.first()).isEqualTo(TimeFormatPref.AUTO) + prefs.setTimeFormat(TimeFormatPref.TWELVE_HOUR) + assertThat(prefs.timeFormat.first()).isEqualTo(TimeFormatPref.TWELVE_HOUR) + } + + @Test + fun `hour lines default off and round-trips`(@TempDir tempDir: Path) = runTest { + val prefs = SettingsPrefs(newDataStore(tempDir)) + assertThat(prefs.showHourLines.first()).isFalse() + prefs.setShowHourLines(true) + assertThat(prefs.showHourLines.first()).isTrue() + } + @Test fun `garbage stored enum falls back to default`(@TempDir tempDir: Path) = runTest { val store = newDataStore(tempDir) From add88fbadfd2a2dff7447f10b3ffc28d43a23cf7 Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Sat, 27 Jun 2026 14:15:46 +0200 Subject: [PATCH 04/26] feat(agenda): limit how far ahead the agenda screen and widget show MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add independent agendaScreenRange and agendaWidgetRange preferences, each a rolling window: 1 day / 1 week / 1 month / custom 1–365 days (default Month). Rolling (not calendar-aligned) so the span never degenerates near a period boundary. The in-app Agenda screen and the agenda widget each read their own setting, configured via a new AgendaRangePicker with an inline custom-days editor. Closes #4 (Codeberg) Co-Authored-By: Claude Opus 4.8 (1M context) --- .../calendula/data/prefs/SettingsPrefs.kt | 27 ++++ .../calendula/ui/agenda/AgendaRange.kt | 54 ++++++++ .../calendula/ui/agenda/AgendaViewModel.kt | 16 ++- .../calendula/ui/common/Picker.kt | 126 ++++++++++++++++++ .../calendula/ui/settings/SettingsScreen.kt | 34 ++++- .../calendula/ui/settings/SettingsUiState.kt | 5 + .../ui/settings/SettingsViewModel.kt | 27 +++- .../calendula/widget/WidgetData.kt | 10 +- app/src/main/res/values-de/strings.xml | 11 ++ app/src/main/res/values/strings.xml | 11 ++ .../calendula/data/prefs/SettingsPrefsTest.kt | 13 ++ .../calendula/ui/agenda/AgendaRangeTest.kt | 50 +++++++ 12 files changed, 368 insertions(+), 16 deletions(-) create mode 100644 app/src/main/java/de/jeanlucmakiola/calendula/ui/agenda/AgendaRange.kt create mode 100644 app/src/test/java/de/jeanlucmakiola/calendula/ui/agenda/AgendaRangeTest.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 2b3cb82..aaa9f0e 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 @@ -7,6 +7,9 @@ import androidx.datastore.preferences.core.edit import androidx.datastore.preferences.core.intPreferencesKey import androidx.datastore.preferences.core.stringPreferencesKey import de.jeanlucmakiola.calendula.domain.EventFormField +import de.jeanlucmakiola.calendula.ui.agenda.AgendaRange +import de.jeanlucmakiola.calendula.ui.agenda.parseAgendaRange +import de.jeanlucmakiola.calendula.ui.agenda.storageValue import de.jeanlucmakiola.calendula.ui.common.CalendarView import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.map @@ -70,6 +73,28 @@ class SettingsPrefs @Inject constructor( store.edit { it[WEEK_START_KEY] = pref.name } } + /** + * How far ahead the in-app Agenda screen shows events (v2.11). Defaults to + * [AgendaRange.Month] — a month of upcoming events. Independent of the + * widget's [agendaWidgetRange]. + */ + val agendaScreenRange: Flow = store.data.map { prefs -> + parseAgendaRange(prefs[AGENDA_SCREEN_RANGE_KEY], AgendaRange.Month) + } + + suspend fun setAgendaScreenRange(range: AgendaRange) { + store.edit { it[AGENDA_SCREEN_RANGE_KEY] = range.storageValue() } + } + + /** How far ahead the agenda **widget** shows events (v2.11). Defaults to Month. */ + val agendaWidgetRange: Flow = store.data.map { prefs -> + parseAgendaRange(prefs[AGENDA_WIDGET_RANGE_KEY], AgendaRange.Month) + } + + suspend fun setAgendaWidgetRange(range: AgendaRange) { + store.edit { it[AGENDA_WIDGET_RANGE_KEY] = range.storageValue() } + } + /** * The calendar view the app opens on (M1). Defaults to [CalendarView.Week] — * the historical hard-coded startup view — so existing users see no change @@ -259,6 +284,8 @@ class SettingsPrefs @Inject constructor( internal val THEME_MODE_KEY = stringPreferencesKey("theme_mode") internal val DYNAMIC_COLOR_KEY = booleanPreferencesKey("dynamic_color") internal val WEEK_START_KEY = stringPreferencesKey("week_start") + internal val AGENDA_SCREEN_RANGE_KEY = stringPreferencesKey("agenda_screen_range") + internal val AGENDA_WIDGET_RANGE_KEY = stringPreferencesKey("agenda_widget_range") internal val DEFAULT_VIEW_KEY = stringPreferencesKey("default_view") internal val FORM_FIELDS_KEY = stringPreferencesKey("event_form_default_fields") internal val REMINDERS_ENABLED_KEY = booleanPreferencesKey("reminders_enabled") 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 new file mode 100644 index 0000000..72106c4 --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/agenda/AgendaRange.kt @@ -0,0 +1,54 @@ +package de.jeanlucmakiola.calendula.ui.agenda + +/** + * How far ahead the agenda (screen or widget) shows events, as a rolling window + * starting at today. [Day]/[Week]/[Month] are fixed 1/7/30-day windows; [Custom] + * is an arbitrary day count. Rolling (not calendar-aligned) windows so the span + * never degenerates near a week/month boundary. + */ +sealed interface AgendaRange { + data object Day : AgendaRange + data object Week : AgendaRange + data object Month : AgendaRange + data class Custom(val days: Int) : AgendaRange + + companion object { + /** Allowed bounds for a [Custom] day count. */ + const val MIN_CUSTOM_DAYS = 1 + const val MAX_CUSTOM_DAYS = 365 + } +} + +/** Inclusive number of days the window spans, starting at (and including) today. */ +fun AgendaRange.dayCount(): Int = when (this) { + AgendaRange.Day -> 1 + AgendaRange.Week -> 7 + AgendaRange.Month -> 30 + is AgendaRange.Custom -> days.coerceIn(AgendaRange.MIN_CUSTOM_DAYS, AgendaRange.MAX_CUSTOM_DAYS) +} + +/** Stored representation: a fixed token, or `CUSTOM:`. */ +fun AgendaRange.storageValue(): String = when (this) { + AgendaRange.Day -> "DAY" + AgendaRange.Week -> "WEEK" + AgendaRange.Month -> "MONTH" + is AgendaRange.Custom -> "$CUSTOM_PREFIX$days" +} + +/** Parse a stored value; unknown/garbage falls back to [default]. */ +fun parseAgendaRange(stored: String?, default: AgendaRange): AgendaRange = when { + stored == "DAY" -> AgendaRange.Day + stored == "WEEK" -> AgendaRange.Week + stored == "MONTH" -> AgendaRange.Month + stored != null && stored.startsWith(CUSTOM_PREFIX) -> { + val days = stored.removePrefix(CUSTOM_PREFIX).toIntOrNull() + if (days != null) { + AgendaRange.Custom(days.coerceIn(AgendaRange.MIN_CUSTOM_DAYS, AgendaRange.MAX_CUSTOM_DAYS)) + } else { + default + } + } + else -> default +} + +private const val CUSTOM_PREFIX = "CUSTOM:" 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 226938d..65f2f1a 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 @@ -5,6 +5,7 @@ import androidx.lifecycle.viewModelScope 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.domain.CalendarSource import de.jeanlucmakiola.calendula.domain.EventInstance import de.jeanlucmakiola.calendula.domain.FailureReason @@ -30,16 +31,16 @@ import kotlin.time.Clock import kotlin.time.Instant import javax.inject.Inject -/** How far ahead the agenda loads events from its anchor day. */ -internal const val AGENDA_WINDOW_DAYS = 60 - @OptIn(ExperimentalCoroutinesApi::class) @HiltViewModel class AgendaViewModel @Inject constructor( private val repository: CalendarRepository, + settingsPrefs: SettingsPrefs, @IoDispatcher private val io: CoroutineDispatcher, ) : ViewModel() { + private val screenRange = settingsPrefs.agendaScreenRange + private val zone = TimeZone.currentSystemDefault() private val todayDate: LocalDate @@ -48,12 +49,13 @@ class AgendaViewModel @Inject constructor( private val _anchor = MutableStateFlow(todayDate) val anchor: StateFlow = _anchor - val state: StateFlow = _anchor - .flatMapLatest { anchor -> - val range = agendaRange(anchor, AGENDA_WINDOW_DAYS, zone) + val state: StateFlow = + combine(_anchor, screenRange) { anchor, range -> anchor to range } + .flatMapLatest { (anchor, range) -> + val window = agendaRange(anchor, range.dayCount() - 1, zone) combine( repository.calendars(), - repository.instances(range), + repository.instances(window), ) { calendars, instances -> buildState(anchor, calendars, instances) } 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 d5d72d2..afbeeed 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 @@ -30,6 +30,7 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalView +import androidx.compose.ui.res.pluralStringResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import androidx.compose.ui.window.Dialog @@ -37,6 +38,7 @@ import androidx.compose.ui.window.DialogProperties import androidx.compose.ui.window.DialogWindowProvider import de.jeanlucmakiola.calendula.R import de.jeanlucmakiola.calendula.data.prefs.CalendarReminderOverride +import de.jeanlucmakiola.calendula.ui.agenda.AgendaRange /** * Shared full-screen scaffold for selection pickers: a full-bleed [Dialog] that @@ -269,6 +271,130 @@ private fun SelectedCheck() { ) } +/** + * Agenda-range picker, full-screen: the fixed 1 day / 1 week / 1 month windows + * as grouped rows, plus a "Custom" row that expands an inline day-count editor + * (1–365). Mirrors [ReminderDefaultPicker]'s custom-expand pattern. + */ +@Composable +fun AgendaRangePicker( + title: String, + selected: AgendaRange, + onSelect: (AgendaRange) -> Unit, + onDismiss: () -> Unit, +) { + val presets = listOf(AgendaRange.Day, AgendaRange.Week, AgendaRange.Month) + val customSelected = selected is AgendaRange.Custom + val rowCount = presets.size + 1 // + the custom row + + var customExpanded by rememberSaveable { mutableStateOf(false) } + var amountText by rememberSaveable { + mutableStateOf((selected as? AgendaRange.Custom)?.days?.toString() ?: "") + } + + FullScreenPicker(title = title, onDismiss = onDismiss) { + presets.forEachIndexed { index, option -> + val isSelected = option == selected + GroupedRow( + title = agendaRangeLabel(option), + position = positionOf(index, rowCount), + selected = isSelected, + trailing = if (isSelected) { + { SelectedCheck() } + } else { + null + }, + onClick = { + onSelect(option) + onDismiss() + }, + ) + } + GroupedRow( + title = if (customSelected) { + agendaRangeLabel(selected) + } else { + stringResource(R.string.agenda_range_custom) + }, + position = if (customExpanded) Position.Top else positionOf(presets.size, rowCount), + selected = customSelected, + trailing = if (customSelected) { + { SelectedCheck() } + } else { + null + }, + onClick = { customExpanded = !customExpanded }, + ) + AnimatedVisibility( + visible = customExpanded, + enter = calendarExpandEnter(), + exit = calendarCollapseExit(), + ) { + CustomDaysEditor( + amountText = amountText, + onAmountChange = { amountText = it }, + onConfirm = { days -> + onSelect(AgendaRange.Custom(days)) + onDismiss() + }, + ) + } + } +} + +/** The expanded "Custom" day-count editor: an amount field (1–365) and Set. */ +@Composable +private fun CustomDaysEditor( + amountText: String, + onAmountChange: (String) -> Unit, + onConfirm: (Int) -> Unit, +) { + val days = amountText.toIntOrNull() + ?.takeIf { it in AgendaRange.MIN_CUSTOM_DAYS..AgendaRange.MAX_CUSTOM_DAYS } + Surface( + color = MaterialTheme.colorScheme.surfaceContainerHigh, + shape = RoundedCornerShape(topStart = 6.dp, topEnd = 6.dp, bottomStart = 22.dp, bottomEnd = 22.dp), + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + ) { + Row( + modifier = Modifier.padding(16.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + DialogAmountField( + value = amountText, + onValueChange = onAmountChange, + placeholder = "30", + ) + Spacer(Modifier.width(16.dp)) + Text( + text = days?.let { pluralStringResource(R.plurals.agenda_range_days, it, it) } + ?: stringResource(R.string.agenda_range_custom_hint), + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.weight(1f), + ) + Spacer(Modifier.width(16.dp)) + FilledTonalButton( + onClick = { days?.let(onConfirm) }, + enabled = days != null, + ) { + Text(stringResource(R.string.reminder_custom_set)) + } + } + } +} + +/** Human label for an [AgendaRange] (used by the picker rows and settings summary). */ +@Composable +fun agendaRangeLabel(range: AgendaRange): String = when (range) { + AgendaRange.Day -> stringResource(R.string.agenda_range_day) + AgendaRange.Week -> stringResource(R.string.agenda_range_week) + AgendaRange.Month -> stringResource(R.string.agenda_range_month) + is AgendaRange.Custom -> pluralStringResource(R.plurals.agenda_range_days, range.days, range.days) +} + @Composable private fun reminderOverrideLabel(override: CalendarReminderOverride): String = when (override) { CalendarReminderOverride.Inherit -> stringResource(R.string.reminder_use_default) 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 21a8f7a..fba3edb 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 @@ -92,6 +92,8 @@ import de.jeanlucmakiola.calendula.ui.crash.openIssueTracker import de.jeanlucmakiola.calendula.ui.crash.submitCrashReport import de.jeanlucmakiola.calendula.ui.common.calendarCollapseExit import de.jeanlucmakiola.calendula.ui.common.calendarExpandEnter +import de.jeanlucmakiola.calendula.ui.common.AgendaRangePicker +import de.jeanlucmakiola.calendula.ui.common.agendaRangeLabel import de.jeanlucmakiola.calendula.ui.common.CollapsingScaffold import de.jeanlucmakiola.calendula.ui.common.GroupedRow import de.jeanlucmakiola.calendula.ui.common.IMPLEMENTED_VIEWS @@ -425,6 +427,8 @@ private fun AppearanceScreen( var showTheme by remember { mutableStateOf(false) } var showWeekStart by remember { mutableStateOf(false) } var showDefaultView by remember { mutableStateOf(false) } + var showAgendaScreenRange by remember { mutableStateOf(false) } + var showAgendaWidgetRange by remember { mutableStateOf(false) } CollapsingScaffold( title = stringResource(R.string.settings_section_appearance), @@ -466,9 +470,21 @@ private fun AppearanceScreen( GroupedRow( title = stringResource(R.string.settings_week_start), summary = weekStartLabel(state.weekStart), - position = Position.Bottom, + position = Position.Middle, onClick = { showWeekStart = true }, ) + GroupedRow( + title = stringResource(R.string.settings_agenda_range), + summary = agendaRangeLabel(state.agendaScreenRange), + position = Position.Middle, + onClick = { showAgendaScreenRange = true }, + ) + GroupedRow( + title = stringResource(R.string.settings_agenda_widget_range), + summary = agendaRangeLabel(state.agendaWidgetRange), + position = Position.Bottom, + onClick = { showAgendaWidgetRange = true }, + ) } if (showTheme) { @@ -491,6 +507,22 @@ private fun AppearanceScreen( onDismiss = { showWeekStart = false }, ) } + if (showAgendaScreenRange) { + AgendaRangePicker( + title = stringResource(R.string.settings_agenda_range), + selected = state.agendaScreenRange, + onSelect = viewModel::setAgendaScreenRange, + onDismiss = { showAgendaScreenRange = false }, + ) + } + if (showAgendaWidgetRange) { + AgendaRangePicker( + title = stringResource(R.string.settings_agenda_widget_range), + selected = state.agendaWidgetRange, + onSelect = viewModel::setAgendaWidgetRange, + onDismiss = { showAgendaWidgetRange = false }, + ) + } if (showDefaultView) { OptionPicker( title = stringResource(R.string.settings_default_view), 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 1dac3bb..f45ad64 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 @@ -5,6 +5,7 @@ import de.jeanlucmakiola.calendula.data.prefs.ThemeMode import de.jeanlucmakiola.calendula.data.prefs.WeekStartPref import de.jeanlucmakiola.calendula.domain.CalendarSource import de.jeanlucmakiola.calendula.domain.EventFormField +import de.jeanlucmakiola.calendula.ui.agenda.AgendaRange import de.jeanlucmakiola.calendula.ui.common.CalendarView /** @@ -18,6 +19,10 @@ data class SettingsUiState( val dynamicColor: Boolean = true, val dynamicColorAvailable: Boolean = true, val weekStart: WeekStartPref = WeekStartPref.AUTO, + /** How far ahead the in-app Agenda screen shows events (v2.11). */ + val agendaScreenRange: AgendaRange = AgendaRange.Month, + /** How far ahead the agenda widget shows events (v2.11). */ + val agendaWidgetRange: AgendaRange = AgendaRange.Month, /** The calendar view the app opens on, and the home of the view back stack (M1). */ val defaultView: CalendarView = CalendarView.Week, /** 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 45a3160..c1e1ccf 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 @@ -11,6 +11,7 @@ import de.jeanlucmakiola.calendula.data.prefs.ThemeMode import de.jeanlucmakiola.calendula.data.prefs.WeekStartPref import de.jeanlucmakiola.calendula.domain.CalendarSource import de.jeanlucmakiola.calendula.domain.EventFormField +import de.jeanlucmakiola.calendula.ui.agenda.AgendaRange import de.jeanlucmakiola.calendula.ui.common.CalendarView import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.SharingStarted @@ -73,10 +74,16 @@ class SettingsViewModel @Inject constructor( ) { overrides, allDayOverrides, calendars -> ReminderOverrides(overrides, allDayOverrides, calendars) }, - prefs.defaultView, - ) { base, defaults, overrides, defaultView -> + combine( + prefs.defaultView, + prefs.agendaScreenRange, + prefs.agendaWidgetRange, + ) { view, screenRange, widgetRange -> ViewSettings(view, screenRange, widgetRange) }, + ) { base, defaults, overrides, views -> base.copy( - defaultView = defaultView, + defaultView = views.defaultView, + agendaScreenRange = views.agendaScreenRange, + agendaWidgetRange = views.agendaWidgetRange, allowColorOnUnsupportedCalendars = defaults.allowColor, defaultReminderMinutes = defaults.defaultReminder, defaultAllDayReminderMinutes = defaults.allDayReminder, @@ -106,6 +113,12 @@ class SettingsViewModel @Inject constructor( val calendars: List, ) + private data class ViewSettings( + val defaultView: CalendarView, + val agendaScreenRange: AgendaRange, + val agendaWidgetRange: AgendaRange, + ) + fun setThemeMode(mode: ThemeMode) { viewModelScope.launch { prefs.setThemeMode(mode) } } @@ -118,6 +131,14 @@ class SettingsViewModel @Inject constructor( viewModelScope.launch { prefs.setWeekStart(pref) } } + fun setAgendaScreenRange(range: AgendaRange) { + viewModelScope.launch { prefs.setAgendaScreenRange(range) } + } + + fun setAgendaWidgetRange(range: AgendaRange) { + viewModelScope.launch { prefs.setAgendaWidgetRange(range) } + } + fun setDefaultView(view: CalendarView) { viewModelScope.launch { prefs.setDefaultView(view) } } 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 854a5e0..5cea8c8 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/widget/WidgetData.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/widget/WidgetData.kt @@ -8,6 +8,7 @@ import de.jeanlucmakiola.calendula.data.prefs.resolveFirstDay import de.jeanlucmakiola.calendula.domain.EventInstance import de.jeanlucmakiola.calendula.ui.agenda.AgendaDay import de.jeanlucmakiola.calendula.ui.agenda.agendaRange +import de.jeanlucmakiola.calendula.ui.agenda.dayCount import de.jeanlucmakiola.calendula.ui.agenda.groupAgendaDays import kotlinx.coroutines.flow.first import kotlinx.datetime.DateTimeUnit @@ -23,9 +24,6 @@ import kotlinx.datetime.toLocalDateTime import java.util.Locale import kotlin.time.Clock -/** How far ahead the agenda widget loads (a month of upcoming events). */ -private const val AGENDA_WIDGET_DAYS = 30 - /** * How far either side of today the month widget pre-loads. The displayed month * is chosen reactively in the composition, so one wide read covers ~13 months of @@ -91,8 +89,10 @@ internal suspend fun Context.loadAgendaWidgetData(): AgendaWidgetData { if (!hasCalendarPermission()) return AgendaWidgetData.NeedsPermission val zone = systemZone() val anchor = today(zone) - val repo = widgetEntryPoint().calendarRepository() - val instances = repo.instances(agendaRange(anchor, AGENDA_WIDGET_DAYS, zone)).first() + val ep = widgetEntryPoint() + val range = ep.settingsPrefs().agendaWidgetRange.first() + val window = agendaRange(anchor, range.dayCount() - 1, zone) + val instances = ep.calendarRepository().instances(window).first() return AgendaWidgetData.Ready(today = anchor, days = groupAgendaDays(anchor, instances, zone)) } diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index aa24a30..36c1f53 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -278,6 +278,17 @@ Automatisch Montag Sonntag + Agenda-Zeitraum + Agenda-Widget-Zeitraum + 1 Tag + 1 Woche + 1 Monat + Benutzerdefiniert… + Tage + + %d Tag + %d Tage + Termin-Formular Standardmäßig angezeigte Felder — alles Weitere liegt hinter \"Weitere Felder\" Farben auf nicht unterstützten Kalendern erlauben diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 644286f..c0a7b8f 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -271,6 +271,17 @@ Automatic Monday Sunday + Agenda range + Agenda widget range + 1 day + 1 week + 1 month + Custom… + Days + + %d day + %d days + New event form Fields shown by default — everything else sits behind \"More fields\" Allow colors on unsupported calendars 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 dee5554..652e765 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 @@ -5,6 +5,7 @@ import androidx.datastore.preferences.core.PreferenceDataStoreFactory import androidx.datastore.preferences.core.Preferences import com.google.common.truth.Truth.assertThat import de.jeanlucmakiola.calendula.domain.EventFormField +import de.jeanlucmakiola.calendula.ui.agenda.AgendaRange import kotlinx.coroutines.flow.first import kotlinx.coroutines.test.runTest import kotlinx.datetime.DayOfWeek @@ -49,6 +50,18 @@ class SettingsPrefsTest { assertThat(prefs.weekStart.first()).isEqualTo(WeekStartPref.SUNDAY) } + @Test + fun `agenda ranges default to month and round-trip independently`(@TempDir tempDir: Path) = runTest { + val prefs = SettingsPrefs(newDataStore(tempDir)) + assertThat(prefs.agendaScreenRange.first()).isEqualTo(AgendaRange.Month) + assertThat(prefs.agendaWidgetRange.first()).isEqualTo(AgendaRange.Month) + + prefs.setAgendaScreenRange(AgendaRange.Custom(14)) + prefs.setAgendaWidgetRange(AgendaRange.Week) + assertThat(prefs.agendaScreenRange.first()).isEqualTo(AgendaRange.Custom(14)) + assertThat(prefs.agendaWidgetRange.first()).isEqualTo(AgendaRange.Week) + } + @Test fun `garbage stored enum falls back to default`(@TempDir tempDir: Path) = runTest { val store = newDataStore(tempDir) diff --git a/app/src/test/java/de/jeanlucmakiola/calendula/ui/agenda/AgendaRangeTest.kt b/app/src/test/java/de/jeanlucmakiola/calendula/ui/agenda/AgendaRangeTest.kt new file mode 100644 index 0000000..57d3db7 --- /dev/null +++ b/app/src/test/java/de/jeanlucmakiola/calendula/ui/agenda/AgendaRangeTest.kt @@ -0,0 +1,50 @@ +package de.jeanlucmakiola.calendula.ui.agenda + +import com.google.common.truth.Truth.assertThat +import org.junit.jupiter.api.Test + +class AgendaRangeTest { + + @Test + fun `fixed ranges have 1, 7, 30 day counts`() { + assertThat(AgendaRange.Day.dayCount()).isEqualTo(1) + assertThat(AgendaRange.Week.dayCount()).isEqualTo(7) + assertThat(AgendaRange.Month.dayCount()).isEqualTo(30) + } + + @Test + fun `custom day count is clamped to bounds`() { + assertThat(AgendaRange.Custom(45).dayCount()).isEqualTo(45) + assertThat(AgendaRange.Custom(0).dayCount()).isEqualTo(AgendaRange.MIN_CUSTOM_DAYS) + assertThat(AgendaRange.Custom(9_999).dayCount()).isEqualTo(AgendaRange.MAX_CUSTOM_DAYS) + } + + @Test + fun `fixed ranges round-trip through storage`() { + listOf(AgendaRange.Day, AgendaRange.Week, AgendaRange.Month).forEach { range -> + assertThat(parseAgendaRange(range.storageValue(), default = AgendaRange.Day)) + .isEqualTo(range) + } + } + + @Test + fun `custom range round-trips through storage`() { + val range = AgendaRange.Custom(14) + assertThat(range.storageValue()).isEqualTo("CUSTOM:14") + assertThat(parseAgendaRange("CUSTOM:14", default = AgendaRange.Day)) + .isEqualTo(AgendaRange.Custom(14)) + } + + @Test + fun `garbage and null fall back to the default`() { + assertThat(parseAgendaRange(null, default = AgendaRange.Month)).isEqualTo(AgendaRange.Month) + assertThat(parseAgendaRange("YEAR", default = AgendaRange.Week)).isEqualTo(AgendaRange.Week) + assertThat(parseAgendaRange("CUSTOM:abc", default = AgendaRange.Day)).isEqualTo(AgendaRange.Day) + } + + @Test + fun `out-of-range custom value clamps on parse`() { + assertThat(parseAgendaRange("CUSTOM:9999", default = AgendaRange.Day)) + .isEqualTo(AgendaRange.Custom(AgendaRange.MAX_CUSTOM_DAYS)) + } +} From 766c2ffcf8ad3c11fd3ffe1fac1395dcb7c902bb Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Sat, 27 Jun 2026 14:25:59 +0200 Subject: [PATCH 05/26] feat(agenda): add calendar-aligned "this week"/"this month" ranges MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extend AgendaRange with calendar-aligned windows alongside the rolling ones: ThisWeek runs through the end of the current week (respecting the week-start preference — a Monday start means everything before next Monday), ThisMonth through the last day of the current month. dayCount now takes the anchor day and week-start; the agenda screen and widget resolve the week-start preference and pass it through. Rolling options relabelled ("Today", "Next 7 days", "Next 30 days") to read distinctly from the new calendar-aligned ones. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../calendula/ui/agenda/AgendaRange.kt | 45 ++++++++++++++--- .../calendula/ui/agenda/AgendaViewModel.kt | 15 ++++-- .../calendula/ui/common/Picker.kt | 10 +++- .../calendula/widget/WidgetData.kt | 6 ++- app/src/main/res/values-de/strings.xml | 8 +-- app/src/main/res/values/strings.xml | 8 +-- .../calendula/ui/agenda/AgendaRangeTest.kt | 49 ++++++++++++++++--- 7 files changed, 114 insertions(+), 27 deletions(-) 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 72106c4..e1f3bee 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,15 +1,26 @@ package de.jeanlucmakiola.calendula.ui.agenda +import kotlinx.datetime.DayOfWeek +import kotlinx.datetime.LocalDate +import java.time.YearMonth + /** - * How far ahead the agenda (screen or widget) shows events, as a rolling window - * starting at today. [Day]/[Week]/[Month] are fixed 1/7/30-day windows; [Custom] - * is an arbitrary day count. Rolling (not calendar-aligned) windows so the span - * never degenerates near a week/month boundary. + * How far ahead the agenda (screen or widget) shows events, starting at today. + * + * Two flavours: + * - **Rolling** windows of a fixed length: [Day] (today), [Week] (7 days), + * [Month] (30 days), [Custom] (1–365 days). These never degenerate. + * - **Calendar-aligned** windows that end at a period boundary: [ThisWeek] runs + * through the end of the current week (respecting the week-start preference — + * a Monday start means "everything before next Monday"); [ThisMonth] runs + * through the last day of the current month. These shrink as the period ends. */ sealed interface AgendaRange { data object Day : AgendaRange data object Week : AgendaRange data object Month : AgendaRange + data object ThisWeek : AgendaRange + data object ThisMonth : AgendaRange data class Custom(val days: Int) : AgendaRange companion object { @@ -19,11 +30,27 @@ sealed interface AgendaRange { } } -/** Inclusive number of days the window spans, starting at (and including) today. */ -fun AgendaRange.dayCount(): Int = when (this) { +private const val DAYS_PER_WEEK = 7 + +/** + * Inclusive number of days the window spans, starting at (and including) + * [anchor]. The calendar-aligned ranges depend on the anchor day and, for + * [AgendaRange.ThisWeek], the [weekStart] preference. + */ +fun AgendaRange.dayCount(anchor: LocalDate, weekStart: DayOfWeek): Int = when (this) { AgendaRange.Day -> 1 - AgendaRange.Week -> 7 + AgendaRange.Week -> DAYS_PER_WEEK AgendaRange.Month -> 30 + AgendaRange.ThisWeek -> { + // Days elapsed since the week's first day (0 when today *is* the start), + // so the window is the rest of the week through the day before it repeats. + val sinceWeekStart = ((anchor.dayOfWeek.ordinal - weekStart.ordinal) + DAYS_PER_WEEK) % DAYS_PER_WEEK + DAYS_PER_WEEK - sinceWeekStart + } + AgendaRange.ThisMonth -> { + val daysInMonth = YearMonth.of(anchor.year, anchor.month.ordinal + 1).lengthOfMonth() + daysInMonth - anchor.day + 1 + } is AgendaRange.Custom -> days.coerceIn(AgendaRange.MIN_CUSTOM_DAYS, AgendaRange.MAX_CUSTOM_DAYS) } @@ -32,6 +59,8 @@ fun AgendaRange.storageValue(): String = when (this) { AgendaRange.Day -> "DAY" AgendaRange.Week -> "WEEK" AgendaRange.Month -> "MONTH" + AgendaRange.ThisWeek -> "THIS_WEEK" + AgendaRange.ThisMonth -> "THIS_MONTH" is AgendaRange.Custom -> "$CUSTOM_PREFIX$days" } @@ -40,6 +69,8 @@ fun parseAgendaRange(stored: String?, default: AgendaRange): AgendaRange = when stored == "DAY" -> AgendaRange.Day stored == "WEEK" -> AgendaRange.Week stored == "MONTH" -> AgendaRange.Month + stored == "THIS_WEEK" -> AgendaRange.ThisWeek + stored == "THIS_MONTH" -> AgendaRange.ThisMonth stored != null && stored.startsWith(CUSTOM_PREFIX) -> { val days = stored.removePrefix(CUSTOM_PREFIX).toIntOrNull() if (days != null) { 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 65f2f1a..85a74c2 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 @@ -6,6 +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.domain.CalendarSource import de.jeanlucmakiola.calendula.domain.EventInstance import de.jeanlucmakiola.calendula.domain.FailureReason @@ -18,10 +19,12 @@ 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.LocalDate import kotlinx.datetime.TimeZone +import java.util.Locale import kotlinx.datetime.atStartOfDayIn import kotlinx.datetime.atTime import kotlinx.datetime.plus @@ -41,6 +44,10 @@ class AgendaViewModel @Inject constructor( private val screenRange = settingsPrefs.agendaScreenRange + // First day of the week, for the calendar-aligned "this week" range. + private val weekStartDay = settingsPrefs.weekStart + .map { it.resolveFirstDay(Locale.getDefault()) } + private val zone = TimeZone.currentSystemDefault() private val todayDate: LocalDate @@ -50,9 +57,11 @@ class AgendaViewModel @Inject constructor( val anchor: StateFlow = _anchor val state: StateFlow = - combine(_anchor, screenRange) { anchor, range -> anchor to range } - .flatMapLatest { (anchor, range) -> - val window = agendaRange(anchor, range.dayCount() - 1, zone) + combine(_anchor, screenRange, weekStartDay) { anchor, range, weekStart -> + Triple(anchor, range, weekStart) + } + .flatMapLatest { (anchor, range, weekStart) -> + val window = agendaRange(anchor, range.dayCount(anchor, weekStart) - 1, zone) combine( repository.calendars(), repository.instances(window), 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 afbeeed..4c92b41 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 @@ -283,7 +283,13 @@ fun AgendaRangePicker( onSelect: (AgendaRange) -> Unit, onDismiss: () -> Unit, ) { - val presets = listOf(AgendaRange.Day, AgendaRange.Week, AgendaRange.Month) + val presets = listOf( + AgendaRange.Day, + AgendaRange.ThisWeek, + AgendaRange.ThisMonth, + AgendaRange.Week, + AgendaRange.Month, + ) val customSelected = selected is AgendaRange.Custom val rowCount = presets.size + 1 // + the custom row @@ -390,6 +396,8 @@ private fun CustomDaysEditor( @Composable fun agendaRangeLabel(range: AgendaRange): String = when (range) { AgendaRange.Day -> stringResource(R.string.agenda_range_day) + AgendaRange.ThisWeek -> stringResource(R.string.agenda_range_this_week) + AgendaRange.ThisMonth -> stringResource(R.string.agenda_range_this_month) AgendaRange.Week -> stringResource(R.string.agenda_range_week) AgendaRange.Month -> stringResource(R.string.agenda_range_month) is AgendaRange.Custom -> pluralStringResource(R.plurals.agenda_range_days, range.days, range.days) 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 5cea8c8..fd69f78 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/widget/WidgetData.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/widget/WidgetData.kt @@ -90,8 +90,10 @@ internal suspend fun Context.loadAgendaWidgetData(): AgendaWidgetData { val zone = systemZone() val anchor = today(zone) val ep = widgetEntryPoint() - val range = ep.settingsPrefs().agendaWidgetRange.first() - val window = agendaRange(anchor, range.dayCount() - 1, zone) + val prefs = ep.settingsPrefs() + val range = prefs.agendaWidgetRange.first() + val weekStart = prefs.weekStart.first().resolveFirstDay(Locale.getDefault()) + val window = agendaRange(anchor, range.dayCount(anchor, weekStart) - 1, zone) val instances = ep.calendarRepository().instances(window).first() return AgendaWidgetData.Ready(today = anchor, days = groupAgendaDays(anchor, instances, zone)) } diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index 36c1f53..70eda74 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -280,9 +280,11 @@ Sonntag Agenda-Zeitraum Agenda-Widget-Zeitraum - 1 Tag - 1 Woche - 1 Monat + Heute + Diese Woche + Dieser Monat + Nächste 7 Tage + Nächste 30 Tage Benutzerdefiniert… Tage diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index c0a7b8f..ed53195 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -273,9 +273,11 @@ Sunday Agenda range Agenda widget range - 1 day - 1 week - 1 month + Today + This week + This month + Next 7 days + Next 30 days Custom… Days diff --git a/app/src/test/java/de/jeanlucmakiola/calendula/ui/agenda/AgendaRangeTest.kt b/app/src/test/java/de/jeanlucmakiola/calendula/ui/agenda/AgendaRangeTest.kt index 57d3db7..be030b1 100644 --- a/app/src/test/java/de/jeanlucmakiola/calendula/ui/agenda/AgendaRangeTest.kt +++ b/app/src/test/java/de/jeanlucmakiola/calendula/ui/agenda/AgendaRangeTest.kt @@ -1,27 +1,60 @@ package de.jeanlucmakiola.calendula.ui.agenda import com.google.common.truth.Truth.assertThat +import kotlinx.datetime.DayOfWeek +import kotlinx.datetime.LocalDate import org.junit.jupiter.api.Test class AgendaRangeTest { + // A reference anchor: 2026-06-17 is a Wednesday. + private val wednesday = LocalDate(2026, 6, 17) + @Test - fun `fixed ranges have 1, 7, 30 day counts`() { - assertThat(AgendaRange.Day.dayCount()).isEqualTo(1) - assertThat(AgendaRange.Week.dayCount()).isEqualTo(7) - assertThat(AgendaRange.Month.dayCount()).isEqualTo(30) + fun `rolling ranges have fixed 1, 7, 30 day counts regardless of anchor`() { + assertThat(AgendaRange.Day.dayCount(wednesday, DayOfWeek.MONDAY)).isEqualTo(1) + assertThat(AgendaRange.Week.dayCount(wednesday, DayOfWeek.MONDAY)).isEqualTo(7) + assertThat(AgendaRange.Month.dayCount(wednesday, DayOfWeek.MONDAY)).isEqualTo(30) + } + + @Test + fun `this week runs through the day before the week repeats`() { + // Mon-start week: Wed → Wed,Thu,Fri,Sat,Sun = 5 days (everything before next Monday). + assertThat(AgendaRange.ThisWeek.dayCount(wednesday, DayOfWeek.MONDAY)).isEqualTo(5) + // On the first day of the week the whole 7 days remain. + val monday = LocalDate(2026, 6, 15) + assertThat(AgendaRange.ThisWeek.dayCount(monday, DayOfWeek.MONDAY)).isEqualTo(7) + // A Sunday-start week shifts the boundary: Wed → 4 days left (through Saturday). + assertThat(AgendaRange.ThisWeek.dayCount(wednesday, DayOfWeek.SUNDAY)).isEqualTo(4) + } + + @Test + fun `this month runs through the last day of the month`() { + // June has 30 days: the 17th leaves 14 days (17..30 inclusive). + assertThat(AgendaRange.ThisMonth.dayCount(wednesday, DayOfWeek.MONDAY)).isEqualTo(14) + // February 2026 (non-leap) has 28 days. + assertThat(AgendaRange.ThisMonth.dayCount(LocalDate(2026, 2, 20), DayOfWeek.MONDAY)) + .isEqualTo(9) } @Test fun `custom day count is clamped to bounds`() { - assertThat(AgendaRange.Custom(45).dayCount()).isEqualTo(45) - assertThat(AgendaRange.Custom(0).dayCount()).isEqualTo(AgendaRange.MIN_CUSTOM_DAYS) - assertThat(AgendaRange.Custom(9_999).dayCount()).isEqualTo(AgendaRange.MAX_CUSTOM_DAYS) + assertThat(AgendaRange.Custom(45).dayCount(wednesday, DayOfWeek.MONDAY)).isEqualTo(45) + assertThat(AgendaRange.Custom(0).dayCount(wednesday, DayOfWeek.MONDAY)) + .isEqualTo(AgendaRange.MIN_CUSTOM_DAYS) + assertThat(AgendaRange.Custom(9_999).dayCount(wednesday, DayOfWeek.MONDAY)) + .isEqualTo(AgendaRange.MAX_CUSTOM_DAYS) } @Test fun `fixed ranges round-trip through storage`() { - listOf(AgendaRange.Day, AgendaRange.Week, AgendaRange.Month).forEach { range -> + listOf( + AgendaRange.Day, + AgendaRange.Week, + AgendaRange.Month, + AgendaRange.ThisWeek, + AgendaRange.ThisMonth, + ).forEach { range -> assertThat(parseAgendaRange(range.storageValue(), default = AgendaRange.Day)) .isEqualTo(range) } From 9994e8c534c202219b57abdb0be41173edc6a017 Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Sat, 27 Jun 2026 14:34:48 +0200 Subject: [PATCH 06/26] chore(release): prepare v2.11.0 Bump versionName to 2.11.0 (versionCode 21100) and add the 2.11.0 changelog: any-day week start (#3), 12/24-hour time format (#6), optional timeline hour lines (#5), and agenda range limits (#4). Co-Authored-By: Claude Opus 4.8 (1M context) --- CHANGELOG.md | 27 +++++++++++++++++++ app/build.gradle.kts | 4 +-- .../android/en-US/changelogs/21100.txt | 21 +++++++++++++++ 3 files changed, 50 insertions(+), 2 deletions(-) create mode 100644 fastlane/metadata/android/en-US/changelogs/21100.txt diff --git a/CHANGELOG.md b/CHANGELOG.md index 1e9ac45..307237d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,29 @@ 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). +## [2.11.0] — 2026-06-27 + +### Added +- Start the week on any day. The **Week starts on** setting (Settings → + Appearance) now offers every weekday — not just Monday or Sunday — alongside + the automatic, locale-based default. Thanks to @zmaherdev for the suggestion + ([#3]). +- Choose a 12- or 24-hour clock. A new **Time format** setting (Settings → + Appearance) lets you force a 12-hour (2:00 PM) or 24-hour (14:00) clock, or + follow the system setting automatically. It applies everywhere times appear — + the week and day timelines, agenda, event details, search and reminders — so + the format is now consistent across the whole app. Thanks to @zmaherdev for + the suggestion ([#6]). +- Optional hour lines in the timeline. A new **Hour lines** switch (Settings → + Appearance) draws a faint separator at each hour in the week and day views, + making it easier to see when events start and end. Off by default. Thanks to + @zmaherdev for the suggestion ([#5]). +- Limit how far ahead the agenda looks. New **Agenda range** and **Agenda widget + range** settings (Settings → Appearance) let the agenda screen and its widget + each show just today, the rest of this week, the rest of this month, a rolling + 7 or 30 days, or a custom number of days. "This week" follows your week-start + preference. Thanks to @zmaherdev for the suggestion ([#4]). + ## [2.10.0] — 2026-06-25 ### Added @@ -627,3 +650,7 @@ automatically, with zero telemetry and no internet permission. [#1]: https://codeberg.org/jlmakiola/calendula/issues/1 [#2]: https://codeberg.org/jlmakiola/calendula/issues/2 +[#3]: https://codeberg.org/jlmakiola/calendula/issues/3 +[#4]: https://codeberg.org/jlmakiola/calendula/issues/4 +[#5]: https://codeberg.org/jlmakiola/calendula/issues/5 +[#6]: https://codeberg.org/jlmakiola/calendula/issues/6 diff --git a/app/build.gradle.kts b/app/build.gradle.kts index ad97564..7c9966c 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 = 21000 - versionName = "2.10.0" + versionCode = 21100 + versionName = "2.11.0" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" } diff --git a/fastlane/metadata/android/en-US/changelogs/21100.txt b/fastlane/metadata/android/en-US/changelogs/21100.txt new file mode 100644 index 0000000..7274b4f --- /dev/null +++ b/fastlane/metadata/android/en-US/changelogs/21100.txt @@ -0,0 +1,21 @@ +### Added +- Start the week on any day. The **Week starts on** setting (Settings → + Appearance) now offers every weekday — not just Monday or Sunday — alongside + the automatic, locale-based default. Thanks to @zmaherdev for the suggestion + ([#3]). +- Choose a 12- or 24-hour clock. A new **Time format** setting (Settings → + Appearance) lets you force a 12-hour (2:00 PM) or 24-hour (14:00) clock, or + follow the system setting automatically. It applies everywhere times appear — + the week and day timelines, agenda, event details, search and reminders — so + the format is now consistent across the whole app. Thanks to @zmaherdev for + the suggestion ([#6]). +- Optional hour lines in the timeline. A new **Hour lines** switch (Settings → + Appearance) draws a faint separator at each hour in the week and day views, + making it easier to see when events start and end. Off by default. Thanks to + @zmaherdev for the suggestion ([#5]). +- Limit how far ahead the agenda looks. New **Agenda range** and **Agenda widget + range** settings (Settings → Appearance) let the agenda screen and its widget + each show just today, the rest of this week, the rest of this month, a rolling + 7 or 30 days, or a custom number of days. "This week" follows your week-start + preference. Thanks to @zmaherdev for the suggestion ([#4]). + From b2bfdc0f4288da17b126ad3ba15ec5472f1939d0 Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Sat, 27 Jun 2026 15:36:19 +0200 Subject: [PATCH 07/26] refactor(settings): regroup Appearance into theme / calendar / agenda MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Drop the uniform two-per-card grouping for meaningful sections: theme & colour (2), calendar — default view, week start, time format, hour lines (4), and agenda (2). Co-Authored-By: Claude Opus 4.8 (1M context) --- .../calendula/ui/settings/SettingsScreen.kt | 10 +++------- 1 file changed, 3 insertions(+), 7 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 a3decae..449602e 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 @@ -470,7 +470,7 @@ private fun AppearanceScreen( Spacer(Modifier.height(16.dp)) - // Calendar layout + // Calendar — view, week, and timeline formatting GroupedRow( title = stringResource(R.string.settings_default_view), summary = stringResource(state.defaultView.labelRes), @@ -480,17 +480,13 @@ private fun AppearanceScreen( GroupedRow( title = stringResource(R.string.settings_week_start), summary = weekStartLabel(state.weekStart), - position = Position.Bottom, + position = Position.Middle, onClick = { showWeekStart = true }, ) - - Spacer(Modifier.height(16.dp)) - - // Timeline display GroupedRow( title = stringResource(R.string.settings_time_format), summary = timeFormatLabel(state.timeFormat), - position = Position.Top, + position = Position.Middle, onClick = { showTimeFormat = true }, ) GroupedRow( From 3cf1439850583c3258c7c365d68629473d4c3415 Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Sat, 27 Jun 2026 15:50:18 +0200 Subject: [PATCH 08/26] feat(agenda): bottom-left pill to override the range for the session Add a tonal pill in the agenda's bottom-left corner showing the current range; tapping it opens the existing AgendaRangePicker as a session-only override. The override lives in AgendaViewModel (in-memory), so it survives view switches and rotation but resets to the saved default when the app is relaunched. The pill fills with the primary container while an override is active, and a hint in the picker spells out the temporary nature. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../calendula/ui/agenda/AgendaScreen.kt | 96 +++++++++++++++++-- .../calendula/ui/agenda/AgendaUiState.kt | 4 + .../calendula/ui/agenda/AgendaViewModel.kt | 48 ++++++++-- app/src/main/res/values-de/strings.xml | 1 + app/src/main/res/values/strings.xml | 1 + 5 files changed, 136 insertions(+), 14 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 22e6f33..317d2a7 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 @@ -2,21 +2,25 @@ 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 import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize 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.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.DateRange import androidx.compose.material.icons.filled.EventAvailable import androidx.compose.material.icons.filled.Menu import androidx.compose.material.icons.filled.Search @@ -35,7 +39,10 @@ import androidx.compose.material3.TopAppBarScrollBehavior import androidx.compose.material3.rememberDrawerState import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember 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.clip @@ -47,6 +54,8 @@ 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.AgendaRangePicker +import de.jeanlucmakiola.calendula.ui.common.agendaRangeLabel import de.jeanlucmakiola.calendula.ui.common.calendarAnimateItem import de.jeanlucmakiola.calendula.ui.common.CalendarDrawer import de.jeanlucmakiola.calendula.ui.common.CalendarFabColumn @@ -91,11 +100,13 @@ fun AgendaScreen( val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior() val drawerState = rememberDrawerState(DrawerValue.Closed) val scope = rememberCoroutineScope() + var showRangePicker by remember { mutableStateOf(false) } val isOnToday = when (val s = state) { is AgendaUiState.Success -> s.anchor == s.today else -> true } + val successState = state as? AgendaUiState.Success ModalNavigationDrawer( drawerState = drawerState, @@ -138,13 +149,84 @@ fun AgendaScreen( ) }, ) { innerPadding -> - AgendaContent( - state = state, - onRetry = viewModel::goToToday, - onEventClick = onEventClick, - modifier = Modifier - .padding(innerPadding) - .fillMaxSize(), + Box(modifier = Modifier.fillMaxSize()) { + AgendaContent( + state = state, + onRetry = viewModel::goToToday, + onEventClick = onEventClick, + modifier = Modifier + .padding(innerPadding) + .fillMaxSize(), + ) + // A bottom-left pill to peek at a different range for this + // session, mirroring the FAB column on the right. + successState?.let { s -> + AgendaRangePill( + range = s.range, + isOverride = s.rangeIsOverride, + onClick = { showRangePicker = true }, + modifier = Modifier + .align(Alignment.BottomStart) + .padding(innerPadding) + .padding(16.dp), + ) + } + } + } + } + + if (showRangePicker) { + AgendaRangePicker( + title = stringResource(R.string.settings_agenda_range), + description = stringResource(R.string.agenda_range_override_hint), + selected = successState?.range ?: AgendaRange.Month, + onSelect = viewModel::setRangeOverride, + onDismiss = { showRangePicker = false }, + ) + } +} + +/** + * A compact tonal pill showing the agenda's current range. Tapping it opens the + * range picker as a session-only override. Filled with the primary container + * while an override is active, so the temporary state is obvious. + */ +@Composable +private fun AgendaRangePill( + range: AgendaRange, + isOverride: Boolean, + onClick: () -> Unit, + modifier: Modifier = Modifier, +) { + val container = if (isOverride) { + MaterialTheme.colorScheme.primaryContainer + } else { + MaterialTheme.colorScheme.surfaceContainerHigh + } + val content = if (isOverride) { + MaterialTheme.colorScheme.onPrimaryContainer + } else { + MaterialTheme.colorScheme.onSurfaceVariant + } + Surface( + color = container, + contentColor = content, + shape = RoundedCornerShape(50), + modifier = modifier.clickable(onClick = onClick), + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.padding(horizontal = 16.dp, vertical = 10.dp), + ) { + Icon( + imageVector = Icons.Filled.DateRange, + contentDescription = null, + modifier = Modifier.size(18.dp), + ) + Spacer(Modifier.width(8.dp)) + Text( + text = agendaRangeLabel(range), + style = MaterialTheme.typography.labelLarge, ) } } 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 2ba5c3b..594b8f8 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 @@ -53,5 +53,9 @@ sealed interface AgendaUiState { val anchor: LocalDate, val today: LocalDate, val days: List, + /** The range currently in effect — the saved default or a session override. */ + val range: AgendaRange, + /** True when [range] is a temporary in-view override of the saved default. */ + val rangeIsOverride: Boolean, ) : 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 85a74c2..7b00397 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 @@ -22,6 +22,7 @@ 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 @@ -56,17 +57,31 @@ class AgendaViewModel @Inject constructor( private val _anchor = MutableStateFlow(todayDate) val anchor: StateFlow = _anchor + // A transient, in-view override of the saved agenda range. Held in memory + // (not persisted), so it survives view switches and rotation within the + // session but resets to the saved default when the app is relaunched. + private val _rangeOverride = MutableStateFlow(null) + val state: StateFlow = - combine(_anchor, screenRange, weekStartDay) { anchor, range, weekStart -> - Triple(anchor, range, weekStart) + combine(_anchor, screenRange, _rangeOverride, weekStartDay) { anchor, default, override, weekStart -> + AgendaParams( + anchor = anchor, + range = override ?: default, + rangeIsOverride = override != null && override != default, + weekStart = weekStart, + ) } - .flatMapLatest { (anchor, range, weekStart) -> - val window = agendaRange(anchor, range.dayCount(anchor, weekStart) - 1, zone) + .flatMapLatest { params -> + val window = agendaRange( + params.anchor, + params.range.dayCount(params.anchor, params.weekStart) - 1, + zone, + ) combine( repository.calendars(), repository.instances(window), ) { calendars, instances -> - buildState(anchor, calendars, instances) + buildState(params, calendars, instances) } } .catch { emit(AgendaUiState.Failure(FailureReason.ProviderUnavailable)) } @@ -86,16 +101,35 @@ class AgendaViewModel @Inject constructor( _anchor.value = date } + /** Temporarily override the agenda range for this session (the bottom-left pill). */ + fun setRangeOverride(range: AgendaRange) { + _rangeOverride.value = range + } + + private data class AgendaParams( + val anchor: LocalDate, + val range: AgendaRange, + val rangeIsOverride: Boolean, + val weekStart: DayOfWeek, + ) + private fun buildState( - anchor: LocalDate, + params: AgendaParams, calendars: List, instances: List, ): AgendaUiState { if (calendars.isEmpty()) { return AgendaUiState.Failure(FailureReason.NoCalendarsConfigured) } + val anchor = params.anchor val days = groupAgendaDays(anchor, instances, zone) - return AgendaUiState.Success(anchor = anchor, today = todayDate, days = days) + return AgendaUiState.Success( + anchor = anchor, + today = todayDate, + days = days, + range = params.range, + rangeIsOverride = params.rangeIsOverride, + ) } } diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index 3b5477b..a5907ef 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -293,6 +293,7 @@ Nächste 30 Tage Benutzerdefiniert… Tage + Nur vorübergehend — wird beim erneuten Öffnen von Calendula auf deinen gespeicherten Zeitraum zurückgesetzt. %d Tag %d Tage diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 74ca96e..dff94df 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -286,6 +286,7 @@ Next 30 days Custom… Days + Just for now — resets to your saved range when you reopen Calendula. %d day %d days From 366059b0129a21c3b4a06dc491c257ea79b6fd05 Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Sat, 27 Jun 2026 15:59:56 +0200 Subject: [PATCH 09/26] feat(agenda): split range picker into two lists with a concrete header MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Group the range options into calendar-aligned (today / this week / this month) and rolling (next 7 / 30 days / custom) lists. Add a header showing the concrete span currently in effect — a single date for Today, the month and year for This month, otherwise a start–end span — so it's clear what the agenda is showing. The header appears only where a window is supplied (the agenda pill), not in Settings. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../calendula/ui/agenda/AgendaRange.kt | 26 ++++++ .../calendula/ui/agenda/AgendaScreen.kt | 4 + .../calendula/ui/agenda/AgendaUiState.kt | 2 + .../calendula/ui/agenda/AgendaViewModel.kt | 5 ++ .../calendula/ui/common/Picker.kt | 86 +++++++++++++------ app/src/main/res/values-de/strings.xml | 1 + app/src/main/res/values/strings.xml | 1 + 7 files changed, 97 insertions(+), 28 deletions(-) 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 e1f3bee..1ae2046 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 @@ -3,6 +3,9 @@ package de.jeanlucmakiola.calendula.ui.agenda import kotlinx.datetime.DayOfWeek import kotlinx.datetime.LocalDate import java.time.YearMonth +import java.time.format.DateTimeFormatter +import java.time.format.FormatStyle +import java.util.Locale /** * How far ahead the agenda (screen or widget) shows events, starting at today. @@ -82,4 +85,27 @@ fun parseAgendaRange(stored: String?, default: AgendaRange): AgendaRange = when else -> default } +/** + * The concrete span the range covers, starting at [start] through [end] + * (inclusive), for a human-readable header: + * - [AgendaRange.Day] → a single medium date ("27 Jun 2026") + * - [AgendaRange.ThisMonth] → month and year ("June 2026") + * - everything else → "start – end" ("27 Jun – 3 Jul 2026") + */ +fun agendaRangeWindowSummary( + range: AgendaRange, + start: LocalDate, + end: LocalDate, + locale: Locale, +): String { + val javaStart = java.time.LocalDate.of(start.year, start.month.ordinal + 1, start.day) + val javaEnd = java.time.LocalDate.of(end.year, end.month.ordinal + 1, end.day) + val medium = DateTimeFormatter.ofLocalizedDate(FormatStyle.MEDIUM).withLocale(locale) + return when (range) { + AgendaRange.Day -> medium.format(javaStart) + AgendaRange.ThisMonth -> DateTimeFormatter.ofPattern("LLLL yyyy", locale).format(javaStart) + else -> "${DateTimeFormatter.ofPattern("d MMM", locale).format(javaStart)} – ${medium.format(javaEnd)}" + } +} + private const val CUSTOM_PREFIX = "CUSTOM:" 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 317d2a7..4b76191 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 @@ -176,12 +176,16 @@ fun AgendaScreen( } if (showRangePicker) { + val locale = currentLocale() AgendaRangePicker( title = stringResource(R.string.settings_agenda_range), description = stringResource(R.string.agenda_range_override_hint), selected = successState?.range ?: AgendaRange.Month, onSelect = viewModel::setRangeOverride, onDismiss = { showRangePicker = false }, + currentWindow = successState?.let { + agendaRangeWindowSummary(it.range, it.anchor, it.rangeEnd, 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 594b8f8..5101f75 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 @@ -57,5 +57,7 @@ sealed interface AgendaUiState { val range: AgendaRange, /** True when [range] is a temporary in-view override of the saved default. */ val rangeIsOverride: Boolean, + /** Last day the current [range] covers (inclusive), for the range header. */ + val rangeEnd: LocalDate, ) : 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 7b00397..ae82858 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 @@ -123,12 +123,17 @@ class AgendaViewModel @Inject constructor( } val anchor = params.anchor val days = groupAgendaDays(anchor, instances, zone) + val rangeEnd = anchor.plus( + params.range.dayCount(anchor, params.weekStart) - 1, + DateTimeUnit.DAY, + ) return AgendaUiState.Success( anchor = anchor, today = todayDate, days = days, range = params.range, rangeIsOverride = params.rangeIsOverride, + rangeEnd = rangeEnd, ) } } 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 78df630..e1f8d19 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 @@ -7,6 +7,7 @@ import androidx.compose.foundation.layout.ColumnScope 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.width import androidx.compose.foundation.shape.RoundedCornerShape @@ -283,9 +284,11 @@ private fun SelectedCheck() { } /** - * Agenda-range picker, full-screen: the fixed 1 day / 1 week / 1 month windows - * as grouped rows, plus a "Custom" row that expands an inline day-count editor - * (1–365). Mirrors [ReminderDefaultPicker]'s custom-expand pattern. + * Agenda-range picker, full-screen. Two grouped lists — the calendar-aligned + * options (today / this week / this month) and the rolling windows (next 7 / 30 + * days), with a "Custom" row that expands an inline day-count editor (1–365). + * When [currentWindow] is given, a header shows the concrete span in effect. + * Mirrors [ReminderDefaultPicker]'s custom-expand pattern. */ @Composable fun AgendaRangePicker( @@ -294,40 +297,50 @@ fun AgendaRangePicker( selected: AgendaRange, onSelect: (AgendaRange) -> Unit, onDismiss: () -> Unit, + currentWindow: String? = null, ) { - val presets = listOf( - AgendaRange.Day, - AgendaRange.ThisWeek, - AgendaRange.ThisMonth, - AgendaRange.Week, - AgendaRange.Month, - ) + val calendarAligned = listOf(AgendaRange.Day, AgendaRange.ThisWeek, AgendaRange.ThisMonth) + val rolling = listOf(AgendaRange.Week, AgendaRange.Month) + val rollingRowCount = rolling.size + 1 // + the custom row val customSelected = selected is AgendaRange.Custom - val rowCount = presets.size + 1 // + the custom row var customExpanded by rememberSaveable { mutableStateOf(false) } var amountText by rememberSaveable { mutableStateOf((selected as? AgendaRange.Custom)?.days?.toString() ?: "") } + val rangeRow: @Composable (AgendaRange, Position) -> Unit = { option, position -> + val isSelected = option == selected + GroupedRow( + title = agendaRangeLabel(option), + position = position, + selected = isSelected, + trailing = if (isSelected) { + { SelectedCheck() } + } else { + null + }, + onClick = { + onSelect(option) + onDismiss() + }, + ) + } + FullScreenPicker(title = title, onDismiss = onDismiss) { + if (currentWindow != null) AgendaRangeHeader(currentWindow) PickerDescription(description) - presets.forEachIndexed { index, option -> - val isSelected = option == selected - GroupedRow( - title = agendaRangeLabel(option), - position = positionOf(index, rowCount), - selected = isSelected, - trailing = if (isSelected) { - { SelectedCheck() } - } else { - null - }, - onClick = { - onSelect(option) - onDismiss() - }, - ) + + // Calendar-aligned windows. + calendarAligned.forEachIndexed { index, option -> + rangeRow(option, positionOf(index, calendarAligned.size)) + } + + Spacer(Modifier.height(12.dp)) + + // Rolling windows + custom. + rolling.forEachIndexed { index, option -> + rangeRow(option, positionOf(index, rollingRowCount)) } GroupedRow( title = if (customSelected) { @@ -335,7 +348,7 @@ fun AgendaRangePicker( } else { stringResource(R.string.agenda_range_custom) }, - position = if (customExpanded) Position.Top else positionOf(presets.size, rowCount), + position = if (customExpanded) Position.Top else positionOf(rolling.size, rollingRowCount), selected = customSelected, trailing = if (customSelected) { { SelectedCheck() } @@ -361,6 +374,23 @@ fun AgendaRangePicker( } } +/** The "Showing all events for " header at the top of the range picker. */ +@Composable +private fun AgendaRangeHeader(window: String) { + Column(modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp)) { + Text( + text = stringResource(R.string.agenda_range_showing_label), + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Text( + text = window, + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.onSurface, + ) + } +} + /** The expanded "Custom" day-count editor: an amount field (1–365) and Set. */ @Composable private fun CustomDaysEditor( diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index a5907ef..51d28c3 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -294,6 +294,7 @@ Benutzerdefiniert… Tage Nur vorübergehend — wird beim erneuten Öffnen von Calendula auf deinen gespeicherten Zeitraum zurückgesetzt. + Alle Termine für %d Tag %d Tage diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index dff94df..4cd9e56 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -287,6 +287,7 @@ Custom… Days Just for now — resets to your saved range when you reopen Calendula. + Showing all events for %d day %d days From 6ba7f38887369b727d8d326e1704efd9763c6661 Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Sat, 27 Jun 2026 16:08:23 +0200 Subject: [PATCH 10/26] feat(agenda): range header banner + toggles for banner and pill Move the "Showing all events for " line out of the picker and into the agenda view as an always-visible header above the list. Add two settings (Agenda group, both ON by default) to toggle the range header banner and the bottom-left range pill independently. The picker keeps its two-list grouping but no longer carries the header. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../calendula/data/prefs/SettingsPrefs.kt | 20 +++++++ .../calendula/ui/agenda/AgendaScreen.kt | 57 +++++++++++++++---- .../calendula/ui/agenda/AgendaUiState.kt | 4 ++ .../calendula/ui/agenda/AgendaViewModel.kt | 25 ++++++-- .../calendula/ui/common/Picker.kt | 20 ------- .../calendula/ui/settings/SettingsScreen.kt | 26 ++++++++- .../calendula/ui/settings/SettingsUiState.kt | 4 ++ .../ui/settings/SettingsViewModel.kt | 16 +++++- app/src/main/res/values-de/strings.xml | 4 ++ app/src/main/res/values/strings.xml | 4 ++ 10 files changed, 142 insertions(+), 38 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 99f1334..1cb6020 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 @@ -137,6 +137,24 @@ class SettingsPrefs @Inject constructor( store.edit { it[AGENDA_WIDGET_RANGE_KEY] = range.storageValue() } } + /** Whether the agenda shows its range-header banner (v2.11). Default ON. */ + val agendaShowRangeBanner: Flow = store.data.map { prefs -> + prefs[AGENDA_SHOW_RANGE_BANNER_KEY] ?: true + } + + suspend fun setAgendaShowRangeBanner(enabled: Boolean) { + store.edit { it[AGENDA_SHOW_RANGE_BANNER_KEY] = enabled } + } + + /** Whether the agenda shows the bottom-left range pill (v2.11). Default ON. */ + val agendaShowRangePill: Flow = store.data.map { prefs -> + prefs[AGENDA_SHOW_RANGE_PILL_KEY] ?: true + } + + suspend fun setAgendaShowRangePill(enabled: Boolean) { + store.edit { it[AGENDA_SHOW_RANGE_PILL_KEY] = enabled } + } + /** * The calendar view the app opens on (M1). Defaults to [CalendarView.Week] — * the historical hard-coded startup view — so existing users see no change @@ -328,6 +346,8 @@ class SettingsPrefs @Inject constructor( internal val WEEK_START_KEY = stringPreferencesKey("week_start") internal val AGENDA_SCREEN_RANGE_KEY = stringPreferencesKey("agenda_screen_range") internal val AGENDA_WIDGET_RANGE_KEY = stringPreferencesKey("agenda_widget_range") + internal val AGENDA_SHOW_RANGE_BANNER_KEY = booleanPreferencesKey("agenda_show_range_banner") + internal val AGENDA_SHOW_RANGE_PILL_KEY = booleanPreferencesKey("agenda_show_range_pill") internal val TIME_FORMAT_KEY = stringPreferencesKey("time_format") internal val SHOW_HOUR_LINES_KEY = booleanPreferencesKey("show_hour_lines") internal val DEFAULT_VIEW_KEY = stringPreferencesKey("default_view") 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 4b76191..9f28a22 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 @@ -150,17 +150,27 @@ fun AgendaScreen( }, ) { innerPadding -> Box(modifier = Modifier.fillMaxSize()) { - AgendaContent( - state = state, - onRetry = viewModel::goToToday, - onEventClick = onEventClick, + Column( modifier = Modifier - .padding(innerPadding) - .fillMaxSize(), - ) + .fillMaxSize() + .padding(innerPadding), + ) { + // A header naming the concrete window currently shown. + successState?.takeIf { it.showRangeBanner }?.let { s -> + AgendaRangeBanner(range = s.range, start = s.anchor, end = s.rangeEnd) + } + AgendaContent( + state = state, + onRetry = viewModel::goToToday, + onEventClick = onEventClick, + modifier = Modifier + .weight(1f) + .fillMaxWidth(), + ) + } // A bottom-left pill to peek at a different range for this // session, mirroring the FAB column on the right. - successState?.let { s -> + successState?.takeIf { it.showRangePill }?.let { s -> AgendaRangePill( range = s.range, isOverride = s.rangeIsOverride, @@ -176,16 +186,12 @@ fun AgendaScreen( } if (showRangePicker) { - val locale = currentLocale() AgendaRangePicker( title = stringResource(R.string.settings_agenda_range), description = stringResource(R.string.agenda_range_override_hint), selected = successState?.range ?: AgendaRange.Month, onSelect = viewModel::setRangeOverride, onDismiss = { showRangePicker = false }, - currentWindow = successState?.let { - agendaRangeWindowSummary(it.range, it.anchor, it.rangeEnd, locale) - }, ) } } @@ -236,6 +242,33 @@ private fun AgendaRangePill( } } +/** + * A header naming the concrete window currently shown, e.g. "Showing all events + * for · Today, 27 Jun 2026" / "This week, 27 Jun – 3 Jul" / "This month, June 2026". + */ +@Composable +private fun AgendaRangeBanner( + range: AgendaRange, + start: LocalDate, + end: LocalDate, + modifier: Modifier = Modifier, +) { + val locale = currentLocale() + val window = agendaRangeWindowSummary(range, start, end, locale) + Column(modifier = modifier.padding(horizontal = 28.dp, vertical = 8.dp)) { + Text( + text = stringResource(R.string.agenda_range_showing_label), + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Text( + text = "${agendaRangeLabel(range)}, $window", + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.onSurface, + ) + } +} + @Composable private fun AgendaContent( state: AgendaUiState, 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 5101f75..0b8414c 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 @@ -59,5 +59,9 @@ sealed interface AgendaUiState { val rangeIsOverride: Boolean, /** Last day the current [range] covers (inclusive), for the range header. */ val rangeEnd: LocalDate, + /** Whether to show the range header banner (settings toggle, on by default). */ + val showRangeBanner: Boolean, + /** Whether to show the bottom-left range pill (settings toggle, on by default). */ + val showRangePill: Boolean, ) : 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 ae82858..758e296 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 @@ -43,7 +43,12 @@ class AgendaViewModel @Inject constructor( @IoDispatcher private val io: CoroutineDispatcher, ) : ViewModel() { - private val screenRange = settingsPrefs.agendaScreenRange + // The saved agenda range plus the banner/pill visibility toggles. + private val agendaSettings = combine( + settingsPrefs.agendaScreenRange, + settingsPrefs.agendaShowRangeBanner, + settingsPrefs.agendaShowRangePill, + ) { range, showBanner, showPill -> AgendaSettings(range, showBanner, showPill) } // First day of the week, for the calendar-aligned "this week" range. private val weekStartDay = settingsPrefs.weekStart @@ -63,12 +68,14 @@ class AgendaViewModel @Inject constructor( private val _rangeOverride = MutableStateFlow(null) val state: StateFlow = - combine(_anchor, screenRange, _rangeOverride, weekStartDay) { anchor, default, override, weekStart -> + combine(_anchor, agendaSettings, _rangeOverride, weekStartDay) { anchor, settings, override, weekStart -> AgendaParams( anchor = anchor, - range = override ?: default, - rangeIsOverride = override != null && override != default, + range = override ?: settings.range, + rangeIsOverride = override != null && override != settings.range, weekStart = weekStart, + showRangeBanner = settings.showBanner, + showRangePill = settings.showPill, ) } .flatMapLatest { params -> @@ -106,11 +113,19 @@ class AgendaViewModel @Inject constructor( _rangeOverride.value = range } + private data class AgendaSettings( + val range: AgendaRange, + val showBanner: Boolean, + val showPill: Boolean, + ) + private data class AgendaParams( val anchor: LocalDate, val range: AgendaRange, val rangeIsOverride: Boolean, val weekStart: DayOfWeek, + val showRangeBanner: Boolean, + val showRangePill: Boolean, ) private fun buildState( @@ -134,6 +149,8 @@ class AgendaViewModel @Inject constructor( range = params.range, rangeIsOverride = params.rangeIsOverride, rangeEnd = rangeEnd, + showRangeBanner = params.showRangeBanner, + showRangePill = params.showRangePill, ) } } 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 e1f8d19..05e7ba8 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 @@ -287,7 +287,6 @@ private fun SelectedCheck() { * Agenda-range picker, full-screen. Two grouped lists — the calendar-aligned * options (today / this week / this month) and the rolling windows (next 7 / 30 * days), with a "Custom" row that expands an inline day-count editor (1–365). - * When [currentWindow] is given, a header shows the concrete span in effect. * Mirrors [ReminderDefaultPicker]'s custom-expand pattern. */ @Composable @@ -297,7 +296,6 @@ fun AgendaRangePicker( selected: AgendaRange, onSelect: (AgendaRange) -> Unit, onDismiss: () -> Unit, - currentWindow: String? = null, ) { val calendarAligned = listOf(AgendaRange.Day, AgendaRange.ThisWeek, AgendaRange.ThisMonth) val rolling = listOf(AgendaRange.Week, AgendaRange.Month) @@ -328,7 +326,6 @@ fun AgendaRangePicker( } FullScreenPicker(title = title, onDismiss = onDismiss) { - if (currentWindow != null) AgendaRangeHeader(currentWindow) PickerDescription(description) // Calendar-aligned windows. @@ -374,23 +371,6 @@ fun AgendaRangePicker( } } -/** The "Showing all events for " header at the top of the range picker. */ -@Composable -private fun AgendaRangeHeader(window: String) { - Column(modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp)) { - Text( - text = stringResource(R.string.agenda_range_showing_label), - style = MaterialTheme.typography.labelMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - Text( - text = window, - style = MaterialTheme.typography.titleMedium, - color = MaterialTheme.colorScheme.onSurface, - ) - } -} - /** The expanded "Custom" day-count editor: an amount field (1–365) and Set. */ @Composable private fun CustomDaysEditor( 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 449602e..de031ed 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 @@ -514,9 +514,33 @@ private fun AppearanceScreen( GroupedRow( title = stringResource(R.string.settings_agenda_widget_range), summary = agendaRangeLabel(state.agendaWidgetRange), - position = Position.Bottom, + position = Position.Middle, onClick = { showAgendaWidgetRange = true }, ) + GroupedRow( + title = stringResource(R.string.settings_agenda_range_title), + summary = stringResource(R.string.settings_agenda_range_title_hint), + position = Position.Middle, + trailing = { + Switch( + checked = state.agendaShowRangeBanner, + onCheckedChange = viewModel::setAgendaShowRangeBanner, + ) + }, + onClick = { viewModel.setAgendaShowRangeBanner(!state.agendaShowRangeBanner) }, + ) + GroupedRow( + title = stringResource(R.string.settings_agenda_range_button), + summary = stringResource(R.string.settings_agenda_range_button_hint), + position = Position.Bottom, + trailing = { + Switch( + checked = state.agendaShowRangePill, + onCheckedChange = viewModel::setAgendaShowRangePill, + ) + }, + onClick = { viewModel.setAgendaShowRangePill(!state.agendaShowRangePill) }, + ) } if (showTheme) { 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 3d7b657..fb1958e 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 @@ -28,6 +28,10 @@ data class SettingsUiState( val agendaScreenRange: AgendaRange = AgendaRange.Month, /** How far ahead the agenda widget shows events (v2.11). */ val agendaWidgetRange: AgendaRange = AgendaRange.Month, + /** Whether the agenda shows its range-header banner (v2.11). */ + val agendaShowRangeBanner: Boolean = true, + /** Whether the agenda shows the bottom-left range pill (v2.11). */ + val agendaShowRangePill: Boolean = true, /** The calendar view the app opens on, and the home of the view back stack (M1). */ val defaultView: CalendarView = CalendarView.Week, /** 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 bcd7e3e..740b566 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 @@ -84,13 +84,19 @@ class SettingsViewModel @Inject constructor( ) { view, screenRange, widgetRange, timeFormat, showHourLines -> ViewSettings(view, screenRange, widgetRange, timeFormat, showHourLines) }, - ) { base, defaults, overrides, views -> + combine( + prefs.agendaShowRangeBanner, + prefs.agendaShowRangePill, + ) { banner, pill -> banner to pill }, + ) { base, defaults, overrides, views, agendaToggles -> base.copy( defaultView = views.defaultView, agendaScreenRange = views.agendaScreenRange, agendaWidgetRange = views.agendaWidgetRange, timeFormat = views.timeFormat, showHourLines = views.showHourLines, + agendaShowRangeBanner = agendaToggles.first, + agendaShowRangePill = agendaToggles.second, allowColorOnUnsupportedCalendars = defaults.allowColor, defaultReminderMinutes = defaults.defaultReminder, defaultAllDayReminderMinutes = defaults.allDayReminder, @@ -148,6 +154,14 @@ class SettingsViewModel @Inject constructor( viewModelScope.launch { prefs.setAgendaWidgetRange(range) } } + fun setAgendaShowRangeBanner(enabled: Boolean) { + viewModelScope.launch { prefs.setAgendaShowRangeBanner(enabled) } + } + + fun setAgendaShowRangePill(enabled: Boolean) { + viewModelScope.launch { prefs.setAgendaShowRangePill(enabled) } + } + fun setTimeFormat(pref: TimeFormatPref) { viewModelScope.launch { prefs.setTimeFormat(pref) } } diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index 51d28c3..9a8e1aa 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -286,6 +286,10 @@ Wie weit im Voraus die Agenda-Ansicht Termine anzeigt. Agenda-Widget-Zeitraum Wie weit im Voraus das Agenda-Widget auf dem Startbildschirm Termine anzeigt. + Zeitraum-Titel + Eine Überschrift anzeigen, die den dargestellten Zeitraum benennt + Zeitraum-Schaltfläche + Eine Schaltfläche in der Agenda anzeigen, um den Zeitraum für die Sitzung zu wechseln Heute Diese Woche Dieser Monat diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 4cd9e56..3cbb111 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -279,6 +279,10 @@ How far ahead the Agenda screen lists events. Agenda widget range How far ahead the agenda home-screen widget lists events. + Range title + Show a header naming the dates the agenda is showing + Range button + Show a button in the agenda to switch the range for the session Today This week This month From f8a2569831a24a4714a5d3e025232eb904b19c47 Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Sat, 27 Jun 2026 16:20:33 +0200 Subject: [PATCH 11/26] feat(agenda): warmer empty state + "upcoming" wording Reword the range header to "Showing all upcoming events for". Replace the dry empty state ("Nothing scheduled" + subtitle) with a single warmer line ("You're all caught up"), drop the subtitle, and switch the icon to a coffee cup. The empty title is shared with the agenda widget. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../calendula/ui/agenda/AgendaScreen.kt | 11 ++--------- app/src/main/res/values-de/strings.xml | 5 ++--- app/src/main/res/values/strings.xml | 5 ++--- 3 files changed, 6 insertions(+), 15 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 9f28a22..0a7d9b4 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 @@ -20,8 +20,8 @@ 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.DateRange -import androidx.compose.material.icons.filled.EventAvailable import androidx.compose.material.icons.filled.Menu import androidx.compose.material.icons.filled.Search import androidx.compose.material3.DrawerValue @@ -376,7 +376,7 @@ private fun AgendaEmpty(modifier: Modifier = Modifier) { horizontalAlignment = Alignment.CenterHorizontally, ) { Icon( - imageVector = Icons.Filled.EventAvailable, + imageVector = Icons.Filled.Coffee, contentDescription = null, tint = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.size(48.dp), @@ -387,13 +387,6 @@ private fun AgendaEmpty(modifier: Modifier = Modifier) { style = MaterialTheme.typography.titleMedium, textAlign = TextAlign.Center, ) - Spacer(Modifier.height(4.dp)) - Text( - text = stringResource(R.string.agenda_empty_subtitle), - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - textAlign = TextAlign.Center, - ) } } diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index 9a8e1aa..a1448d2 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -230,8 +230,7 @@ Heute Heute Morgen - Nichts geplant - Anstehende Termine erscheinen hier. + Alles erledigt Suchen @@ -298,7 +297,7 @@ Benutzerdefiniert… Tage Nur vorübergehend — wird beim erneuten Öffnen von Calendula auf deinen gespeicherten Zeitraum zurückgesetzt. - Alle Termine für + Alle anstehenden Termine für %d Tag %d Tage diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 3cbb111..fed0707 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -231,8 +231,7 @@ Today Today Tomorrow - Nothing scheduled - Upcoming events will show up here. + You\'re all caught up Search @@ -291,7 +290,7 @@ Custom… Days Just for now — resets to your saved range when you reopen Calendula. - Showing all events for + Showing all upcoming events for %d day %d days From 6471c48528986c3547ee2da412025e128ea9968d Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Sat, 27 Jun 2026 16:22:33 +0200 Subject: [PATCH 12/26] refactor(agenda): put range header and pill on one bar MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Move the session range pill from the bottom-left corner onto the same top bar as the "Showing all upcoming events for …" header — header on the left, pill on the right, each still independently toggleable. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../calendula/ui/agenda/AgendaScreen.kt | 72 +++++++++++-------- 1 file changed, 41 insertions(+), 31 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 0a7d9b4..7aabfda 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 @@ -149,38 +149,48 @@ fun AgendaScreen( ) }, ) { innerPadding -> - Box(modifier = Modifier.fillMaxSize()) { - Column( - modifier = Modifier - .fillMaxSize() - .padding(innerPadding), - ) { - // A header naming the concrete window currently shown. - successState?.takeIf { it.showRangeBanner }?.let { s -> - AgendaRangeBanner(range = s.range, start = s.anchor, end = s.rangeEnd) + Column( + modifier = Modifier + .fillMaxSize() + .padding(innerPadding), + ) { + // One bar at the top: the "showing …" header on the left and the + // session range pill on the right (each toggleable independently). + val s = successState + if (s != null && (s.showRangeBanner || s.showRangePill)) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .fillMaxWidth() + .padding(start = 28.dp, end = 16.dp, top = 8.dp, bottom = 8.dp), + ) { + if (s.showRangeBanner) { + AgendaRangeBanner( + range = s.range, + start = s.anchor, + end = s.rangeEnd, + modifier = Modifier.weight(1f), + ) + } else { + Spacer(Modifier.weight(1f)) + } + if (s.showRangePill) { + AgendaRangePill( + range = s.range, + isOverride = s.rangeIsOverride, + onClick = { showRangePicker = true }, + ) + } } - AgendaContent( - state = state, - onRetry = viewModel::goToToday, - onEventClick = onEventClick, - modifier = Modifier - .weight(1f) - .fillMaxWidth(), - ) - } - // A bottom-left pill to peek at a different range for this - // session, mirroring the FAB column on the right. - successState?.takeIf { it.showRangePill }?.let { s -> - AgendaRangePill( - range = s.range, - isOverride = s.rangeIsOverride, - onClick = { showRangePicker = true }, - modifier = Modifier - .align(Alignment.BottomStart) - .padding(innerPadding) - .padding(16.dp), - ) } + AgendaContent( + state = state, + onRetry = viewModel::goToToday, + onEventClick = onEventClick, + modifier = Modifier + .weight(1f) + .fillMaxWidth(), + ) } } } @@ -255,7 +265,7 @@ private fun AgendaRangeBanner( ) { val locale = currentLocale() val window = agendaRangeWindowSummary(range, start, end, locale) - Column(modifier = modifier.padding(horizontal = 28.dp, vertical = 8.dp)) { + Column(modifier = modifier) { Text( text = stringResource(R.string.agenda_range_showing_label), style = MaterialTheme.typography.labelMedium, From 0f31981d26fa85f754fc274cec3b82181d04714b Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Sat, 27 Jun 2026 16:25:46 +0200 Subject: [PATCH 13/26] refactor(agenda): one toggle for the whole range bar Consolidate the separate "range title" and "range button" settings into a single "Range bar" toggle, now that the header and switcher live on one bar. On by default. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../calendula/data/prefs/SettingsPrefs.kt | 25 +++++--------- .../calendula/ui/agenda/AgendaScreen.kt | 33 ++++++++----------- .../calendula/ui/agenda/AgendaUiState.kt | 6 ++-- .../calendula/ui/agenda/AgendaViewModel.kt | 19 ++++------- .../calendula/ui/settings/SettingsScreen.kt | 22 +++---------- .../calendula/ui/settings/SettingsUiState.kt | 6 ++-- .../ui/settings/SettingsViewModel.kt | 18 +++------- app/src/main/res/values-de/strings.xml | 6 ++-- app/src/main/res/values/strings.xml | 6 ++-- 9 files changed, 47 insertions(+), 94 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 1cb6020..ef2c757 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 @@ -137,22 +137,16 @@ class SettingsPrefs @Inject constructor( store.edit { it[AGENDA_WIDGET_RANGE_KEY] = range.storageValue() } } - /** Whether the agenda shows its range-header banner (v2.11). Default ON. */ - val agendaShowRangeBanner: Flow = store.data.map { prefs -> - prefs[AGENDA_SHOW_RANGE_BANNER_KEY] ?: true + /** + * Whether the agenda shows its top range bar — the "showing …" header and + * the session range switcher (v2.11). Default ON. + */ + val agendaShowRangeBar: Flow = store.data.map { prefs -> + prefs[AGENDA_SHOW_RANGE_BAR_KEY] ?: true } - suspend fun setAgendaShowRangeBanner(enabled: Boolean) { - store.edit { it[AGENDA_SHOW_RANGE_BANNER_KEY] = enabled } - } - - /** Whether the agenda shows the bottom-left range pill (v2.11). Default ON. */ - val agendaShowRangePill: Flow = store.data.map { prefs -> - prefs[AGENDA_SHOW_RANGE_PILL_KEY] ?: true - } - - suspend fun setAgendaShowRangePill(enabled: Boolean) { - store.edit { it[AGENDA_SHOW_RANGE_PILL_KEY] = enabled } + suspend fun setAgendaShowRangeBar(enabled: Boolean) { + store.edit { it[AGENDA_SHOW_RANGE_BAR_KEY] = enabled } } /** @@ -346,8 +340,7 @@ class SettingsPrefs @Inject constructor( internal val WEEK_START_KEY = stringPreferencesKey("week_start") internal val AGENDA_SCREEN_RANGE_KEY = stringPreferencesKey("agenda_screen_range") internal val AGENDA_WIDGET_RANGE_KEY = stringPreferencesKey("agenda_widget_range") - internal val AGENDA_SHOW_RANGE_BANNER_KEY = booleanPreferencesKey("agenda_show_range_banner") - internal val AGENDA_SHOW_RANGE_PILL_KEY = booleanPreferencesKey("agenda_show_range_pill") + internal val AGENDA_SHOW_RANGE_BAR_KEY = booleanPreferencesKey("agenda_show_range_bar") internal val TIME_FORMAT_KEY = stringPreferencesKey("time_format") internal val SHOW_HOUR_LINES_KEY = booleanPreferencesKey("show_hour_lines") internal val DEFAULT_VIEW_KEY = stringPreferencesKey("default_view") 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 7aabfda..8c6073c 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 @@ -155,32 +155,25 @@ fun AgendaScreen( .padding(innerPadding), ) { // One bar at the top: the "showing …" header on the left and the - // session range pill on the right (each toggleable independently). - val s = successState - if (s != null && (s.showRangeBanner || s.showRangePill)) { + // session range switcher on the right (one settings toggle). + successState?.takeIf { it.showRangeBar }?.let { s -> Row( verticalAlignment = Alignment.CenterVertically, modifier = Modifier .fillMaxWidth() .padding(start = 28.dp, end = 16.dp, top = 8.dp, bottom = 8.dp), ) { - if (s.showRangeBanner) { - AgendaRangeBanner( - range = s.range, - start = s.anchor, - end = s.rangeEnd, - modifier = Modifier.weight(1f), - ) - } else { - Spacer(Modifier.weight(1f)) - } - if (s.showRangePill) { - AgendaRangePill( - range = s.range, - isOverride = s.rangeIsOverride, - onClick = { showRangePicker = true }, - ) - } + AgendaRangeBanner( + range = s.range, + start = s.anchor, + end = s.rangeEnd, + modifier = Modifier.weight(1f), + ) + AgendaRangePill( + range = s.range, + isOverride = s.rangeIsOverride, + onClick = { showRangePicker = true }, + ) } } AgendaContent( 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 0b8414c..110a528 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 @@ -59,9 +59,7 @@ sealed interface AgendaUiState { val rangeIsOverride: Boolean, /** Last day the current [range] covers (inclusive), for the range header. */ val rangeEnd: LocalDate, - /** Whether to show the range header banner (settings toggle, on by default). */ - val showRangeBanner: Boolean, - /** Whether to show the bottom-left range pill (settings toggle, on by default). */ - val showRangePill: Boolean, + /** Whether to show the top range bar — header + switcher (toggle, on by default). */ + val showRangeBar: Boolean, ) : 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 758e296..f088e4e 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 @@ -43,12 +43,11 @@ class AgendaViewModel @Inject constructor( @IoDispatcher private val io: CoroutineDispatcher, ) : ViewModel() { - // The saved agenda range plus the banner/pill visibility toggles. + // The saved agenda range plus the range-bar visibility toggle. private val agendaSettings = combine( settingsPrefs.agendaScreenRange, - settingsPrefs.agendaShowRangeBanner, - settingsPrefs.agendaShowRangePill, - ) { range, showBanner, showPill -> AgendaSettings(range, showBanner, showPill) } + settingsPrefs.agendaShowRangeBar, + ) { range, showBar -> AgendaSettings(range, showBar) } // First day of the week, for the calendar-aligned "this week" range. private val weekStartDay = settingsPrefs.weekStart @@ -74,8 +73,7 @@ class AgendaViewModel @Inject constructor( range = override ?: settings.range, rangeIsOverride = override != null && override != settings.range, weekStart = weekStart, - showRangeBanner = settings.showBanner, - showRangePill = settings.showPill, + showRangeBar = settings.showBar, ) } .flatMapLatest { params -> @@ -115,8 +113,7 @@ class AgendaViewModel @Inject constructor( private data class AgendaSettings( val range: AgendaRange, - val showBanner: Boolean, - val showPill: Boolean, + val showBar: Boolean, ) private data class AgendaParams( @@ -124,8 +121,7 @@ class AgendaViewModel @Inject constructor( val range: AgendaRange, val rangeIsOverride: Boolean, val weekStart: DayOfWeek, - val showRangeBanner: Boolean, - val showRangePill: Boolean, + val showRangeBar: Boolean, ) private fun buildState( @@ -149,8 +145,7 @@ class AgendaViewModel @Inject constructor( range = params.range, rangeIsOverride = params.rangeIsOverride, rangeEnd = rangeEnd, - showRangeBanner = params.showRangeBanner, - showRangePill = params.showRangePill, + showRangeBar = params.showRangeBar, ) } } 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 de031ed..5dc6ef8 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 @@ -518,28 +518,16 @@ private fun AppearanceScreen( onClick = { showAgendaWidgetRange = true }, ) GroupedRow( - title = stringResource(R.string.settings_agenda_range_title), - summary = stringResource(R.string.settings_agenda_range_title_hint), - position = Position.Middle, - trailing = { - Switch( - checked = state.agendaShowRangeBanner, - onCheckedChange = viewModel::setAgendaShowRangeBanner, - ) - }, - onClick = { viewModel.setAgendaShowRangeBanner(!state.agendaShowRangeBanner) }, - ) - GroupedRow( - title = stringResource(R.string.settings_agenda_range_button), - summary = stringResource(R.string.settings_agenda_range_button_hint), + title = stringResource(R.string.settings_agenda_range_bar), + summary = stringResource(R.string.settings_agenda_range_bar_hint), position = Position.Bottom, trailing = { Switch( - checked = state.agendaShowRangePill, - onCheckedChange = viewModel::setAgendaShowRangePill, + checked = state.agendaShowRangeBar, + onCheckedChange = viewModel::setAgendaShowRangeBar, ) }, - onClick = { viewModel.setAgendaShowRangePill(!state.agendaShowRangePill) }, + onClick = { viewModel.setAgendaShowRangeBar(!state.agendaShowRangeBar) }, ) } 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 fb1958e..0507e6a 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 @@ -28,10 +28,8 @@ data class SettingsUiState( val agendaScreenRange: AgendaRange = AgendaRange.Month, /** How far ahead the agenda widget shows events (v2.11). */ val agendaWidgetRange: AgendaRange = AgendaRange.Month, - /** Whether the agenda shows its range-header banner (v2.11). */ - val agendaShowRangeBanner: Boolean = true, - /** Whether the agenda shows the bottom-left range pill (v2.11). */ - val agendaShowRangePill: Boolean = true, + /** Whether the agenda shows its top range bar — header + switcher (v2.11). */ + val agendaShowRangeBar: Boolean = true, /** The calendar view the app opens on, and the home of the view back stack (M1). */ val defaultView: CalendarView = CalendarView.Week, /** 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 740b566..bc080ba 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 @@ -84,19 +84,15 @@ class SettingsViewModel @Inject constructor( ) { view, screenRange, widgetRange, timeFormat, showHourLines -> ViewSettings(view, screenRange, widgetRange, timeFormat, showHourLines) }, - combine( - prefs.agendaShowRangeBanner, - prefs.agendaShowRangePill, - ) { banner, pill -> banner to pill }, - ) { base, defaults, overrides, views, agendaToggles -> + prefs.agendaShowRangeBar, + ) { base, defaults, overrides, views, showRangeBar -> base.copy( defaultView = views.defaultView, agendaScreenRange = views.agendaScreenRange, agendaWidgetRange = views.agendaWidgetRange, timeFormat = views.timeFormat, showHourLines = views.showHourLines, - agendaShowRangeBanner = agendaToggles.first, - agendaShowRangePill = agendaToggles.second, + agendaShowRangeBar = showRangeBar, allowColorOnUnsupportedCalendars = defaults.allowColor, defaultReminderMinutes = defaults.defaultReminder, defaultAllDayReminderMinutes = defaults.allDayReminder, @@ -154,12 +150,8 @@ class SettingsViewModel @Inject constructor( viewModelScope.launch { prefs.setAgendaWidgetRange(range) } } - fun setAgendaShowRangeBanner(enabled: Boolean) { - viewModelScope.launch { prefs.setAgendaShowRangeBanner(enabled) } - } - - fun setAgendaShowRangePill(enabled: Boolean) { - viewModelScope.launch { prefs.setAgendaShowRangePill(enabled) } + fun setAgendaShowRangeBar(enabled: Boolean) { + viewModelScope.launch { prefs.setAgendaShowRangeBar(enabled) } } fun setTimeFormat(pref: TimeFormatPref) { diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index a1448d2..348ba61 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -285,10 +285,8 @@ Wie weit im Voraus die Agenda-Ansicht Termine anzeigt. Agenda-Widget-Zeitraum Wie weit im Voraus das Agenda-Widget auf dem Startbildschirm Termine anzeigt. - Zeitraum-Titel - Eine Überschrift anzeigen, die den dargestellten Zeitraum benennt - Zeitraum-Schaltfläche - Eine Schaltfläche in der Agenda anzeigen, um den Zeitraum für die Sitzung zu wechseln + Zeitraum-Leiste + Eine Leiste oben in der Agenda anzeigen, die den dargestellten Zeitraum benennt, mit einer Schaltfläche zum Wechseln des Zeitraums für die Sitzung Heute Diese Woche Dieser Monat diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index fed0707..60c1872 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -278,10 +278,8 @@ How far ahead the Agenda screen lists events. Agenda widget range How far ahead the agenda home-screen widget lists events. - Range title - Show a header naming the dates the agenda is showing - Range button - Show a button in the agenda to switch the range for the session + Range bar + Show a bar at the top of the agenda naming the dates shown, with a button to switch the range for the session Today This week This month From f27b0e269d8180db32f3e0b2bbd82be438cb7a14 Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Sat, 27 Jun 2026 16:30:27 +0200 Subject: [PATCH 14/26] fix(widget): refresh agenda widget when its range (or week start) changes The agenda widget only re-read agendaWidgetRange on the next data-change / midnight / periodic refresh (or re-placement), so a settings change appeared to do nothing until then. Push an updateAll from the settings setters: the agenda widget on an agenda-widget-range change, and both widgets on a week-start change (month weekday header + the agenda widget's "this week"). Co-Authored-By: Claude Opus 4.8 (1M context) --- .../ui/settings/SettingsViewModel.kt | 21 +++++++++++++++++-- 1 file changed, 19 insertions(+), 2 deletions(-) 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 bc080ba..b55437a 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 @@ -1,9 +1,12 @@ package de.jeanlucmakiola.calendula.ui.settings +import android.content.Context import android.os.Build +import androidx.glance.appwidget.updateAll import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import dagger.hilt.android.lifecycle.HiltViewModel +import dagger.hilt.android.qualifiers.ApplicationContext import de.jeanlucmakiola.calendula.data.calendar.CalendarRepository import de.jeanlucmakiola.calendula.data.prefs.CalendarReminderOverride import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs @@ -14,6 +17,8 @@ import de.jeanlucmakiola.calendula.domain.CalendarSource import de.jeanlucmakiola.calendula.domain.EventFormField import de.jeanlucmakiola.calendula.ui.agenda.AgendaRange import de.jeanlucmakiola.calendula.ui.common.CalendarView +import de.jeanlucmakiola.calendula.widget.agenda.AgendaWidget +import de.jeanlucmakiola.calendula.widget.month.MonthWidget import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow @@ -28,6 +33,7 @@ import javax.inject.Inject class SettingsViewModel @Inject constructor( private val prefs: SettingsPrefs, repository: CalendarRepository, + @ApplicationContext private val appContext: Context, ) : ViewModel() { private val dynamicColorAvailable = Build.VERSION.SDK_INT >= Build.VERSION_CODES.S @@ -139,7 +145,13 @@ class SettingsViewModel @Inject constructor( } fun setWeekStart(pref: WeekStartPref) { - viewModelScope.launch { prefs.setWeekStart(pref) } + viewModelScope.launch { + prefs.setWeekStart(pref) + // Both widgets depend on the week start (month weekday header; the + // agenda widget's "this week" range), so push a redraw. + AgendaWidget().updateAll(appContext) + MonthWidget().updateAll(appContext) + } } fun setAgendaScreenRange(range: AgendaRange) { @@ -147,7 +159,12 @@ class SettingsViewModel @Inject constructor( } fun setAgendaWidgetRange(range: AgendaRange) { - viewModelScope.launch { prefs.setAgendaWidgetRange(range) } + viewModelScope.launch { + prefs.setAgendaWidgetRange(range) + // Push the new range to the placed widget immediately, instead of + // waiting for the next data-change / midnight / periodic refresh. + AgendaWidget().updateAll(appContext) + } } fun setAgendaShowRangeBar(enabled: Boolean) { From cd703e9d540481963e284bb16b0c8d1dd2b0a2bc Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Sat, 27 Jun 2026 16:37:57 +0200 Subject: [PATCH 15/26] fix(widget): debounce widget refresh on pref change Refreshing the widget directly from the setter raced when a setting was flipped and flipped back quickly: two concurrent updateAll calls could coalesce around a stale read and leave the widget on the intermediate value. Observe the agenda-widget-range and week-start prefs instead and push a single debounced updateAll once changes settle, so the widget always converges to the final value. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../ui/settings/SettingsViewModel.kt | 49 ++++++++++++++----- 1 file changed, 36 insertions(+), 13 deletions(-) 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 b55437a..bd1ff95 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 @@ -19,16 +19,25 @@ import de.jeanlucmakiola.calendula.ui.agenda.AgendaRange import de.jeanlucmakiola.calendula.ui.common.CalendarView import de.jeanlucmakiola.calendula.widget.agenda.AgendaWidget import de.jeanlucmakiola.calendula.widget.month.MonthWidget +import kotlinx.coroutines.FlowPreview import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.catch import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.debounce +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.drop +import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch import javax.inject.Inject +private const val WIDGET_REFRESH_DEBOUNCE_MS = 350L + +@OptIn(FlowPreview::class) @HiltViewModel class SettingsViewModel @Inject constructor( private val prefs: SettingsPrefs, @@ -114,6 +123,31 @@ class SettingsViewModel @Inject constructor( initialValue = SettingsUiState(dynamicColorAvailable = dynamicColorAvailable), ) + init { + // Push widget redraws when their backing prefs change, debounced so a + // rapid flip-and-flip-back settles to one update of the final value + // (two racing updateAll calls can otherwise leave the widget stale). + // drop(1) skips the value present at startup — nothing to refresh yet. + prefs.agendaWidgetRange + .drop(1) + .debounce(WIDGET_REFRESH_DEBOUNCE_MS) + .distinctUntilChanged() + .onEach { AgendaWidget().updateAll(appContext) } + .launchIn(viewModelScope) + + // Both widgets depend on the week start (month weekday header; the + // agenda widget's "this week" range). + prefs.weekStart + .drop(1) + .debounce(WIDGET_REFRESH_DEBOUNCE_MS) + .distinctUntilChanged() + .onEach { + AgendaWidget().updateAll(appContext) + MonthWidget().updateAll(appContext) + } + .launchIn(viewModelScope) + } + private data class ReminderDefaults( val allowColor: Boolean, val defaultReminder: Int?, @@ -145,13 +179,7 @@ class SettingsViewModel @Inject constructor( } fun setWeekStart(pref: WeekStartPref) { - viewModelScope.launch { - prefs.setWeekStart(pref) - // Both widgets depend on the week start (month weekday header; the - // agenda widget's "this week" range), so push a redraw. - AgendaWidget().updateAll(appContext) - MonthWidget().updateAll(appContext) - } + viewModelScope.launch { prefs.setWeekStart(pref) } } fun setAgendaScreenRange(range: AgendaRange) { @@ -159,12 +187,7 @@ class SettingsViewModel @Inject constructor( } fun setAgendaWidgetRange(range: AgendaRange) { - viewModelScope.launch { - prefs.setAgendaWidgetRange(range) - // Push the new range to the placed widget immediately, instead of - // waiting for the next data-change / midnight / periodic refresh. - AgendaWidget().updateAll(appContext) - } + viewModelScope.launch { prefs.setAgendaWidgetRange(range) } } fun setAgendaShowRangeBar(enabled: Boolean) { From bb92d020002e3d7709df320e998d3646c3d532b8 Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Sat, 27 Jun 2026 16:44:52 +0200 Subject: [PATCH 16/26] fix(widget): refresh from setter, serialized with a mutex The debounced observer was unreliable: drop(1) plus the ViewModel lifecycle could swallow a change after re-entering the app, so a single range change sometimes didn't refresh the widget. Refresh directly from the settings setters instead (always fires on a real change), serialized through a mutex so a rapid flip-and-flip-back can't run two updateAll calls at once and strand the widget on the intermediate value. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../ui/settings/SettingsViewModel.kt | 55 +++++++------------ 1 file changed, 20 insertions(+), 35 deletions(-) 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 bd1ff95..4adf344 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 @@ -19,25 +19,18 @@ import de.jeanlucmakiola.calendula.ui.agenda.AgendaRange import de.jeanlucmakiola.calendula.ui.common.CalendarView import de.jeanlucmakiola.calendula.widget.agenda.AgendaWidget import de.jeanlucmakiola.calendula.widget.month.MonthWidget -import kotlinx.coroutines.FlowPreview import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.catch import kotlinx.coroutines.flow.combine -import kotlinx.coroutines.flow.debounce -import kotlinx.coroutines.flow.distinctUntilChanged -import kotlinx.coroutines.flow.drop -import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.map -import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock import javax.inject.Inject -private const val WIDGET_REFRESH_DEBOUNCE_MS = 350L - -@OptIn(FlowPreview::class) @HiltViewModel class SettingsViewModel @Inject constructor( private val prefs: SettingsPrefs, @@ -123,30 +116,11 @@ class SettingsViewModel @Inject constructor( initialValue = SettingsUiState(dynamicColorAvailable = dynamicColorAvailable), ) - init { - // Push widget redraws when their backing prefs change, debounced so a - // rapid flip-and-flip-back settles to one update of the final value - // (two racing updateAll calls can otherwise leave the widget stale). - // drop(1) skips the value present at startup — nothing to refresh yet. - prefs.agendaWidgetRange - .drop(1) - .debounce(WIDGET_REFRESH_DEBOUNCE_MS) - .distinctUntilChanged() - .onEach { AgendaWidget().updateAll(appContext) } - .launchIn(viewModelScope) - - // Both widgets depend on the week start (month weekday header; the - // agenda widget's "this week" range). - prefs.weekStart - .drop(1) - .debounce(WIDGET_REFRESH_DEBOUNCE_MS) - .distinctUntilChanged() - .onEach { - AgendaWidget().updateAll(appContext) - MonthWidget().updateAll(appContext) - } - .launchIn(viewModelScope) - } + // Serialises widget redraws so a rapid flip-and-flip-back can't run two + // updateAll calls at once — concurrent calls coalesce around a stale read + // and can leave the widget on the intermediate value. Held sequentially, + // the last call reads the final committed pref and renders it. + private val widgetRefreshMutex = Mutex() private data class ReminderDefaults( val allowColor: Boolean, @@ -179,7 +153,15 @@ class SettingsViewModel @Inject constructor( } fun setWeekStart(pref: WeekStartPref) { - viewModelScope.launch { prefs.setWeekStart(pref) } + viewModelScope.launch { + prefs.setWeekStart(pref) + // Both widgets depend on the week start (month weekday header; the + // agenda widget's "this week" range). + widgetRefreshMutex.withLock { + AgendaWidget().updateAll(appContext) + MonthWidget().updateAll(appContext) + } + } } fun setAgendaScreenRange(range: AgendaRange) { @@ -187,7 +169,10 @@ class SettingsViewModel @Inject constructor( } fun setAgendaWidgetRange(range: AgendaRange) { - viewModelScope.launch { prefs.setAgendaWidgetRange(range) } + viewModelScope.launch { + prefs.setAgendaWidgetRange(range) + widgetRefreshMutex.withLock { AgendaWidget().updateAll(appContext) } + } } fun setAgendaShowRangeBar(enabled: Boolean) { From c30a1535524235027cb9b3808f226686853f9b1e Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Sat, 27 Jun 2026 17:03:48 +0200 Subject: [PATCH 17/26] fix(widget): drive agenda range via reactive Glance state MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The agenda widget loaded and sliced its data in the provideGlance preamble and captured it as a non-reactive local. updateAll() reliably recomposes a live Glance session but does not reliably re-run that preamble, so a range change was redrawn against the stale slice (intermittently — only when no session was alive did it pick up the new range). This is the same platform limitation the month widget already works around. Mirror that pattern: load the widest selectable window once, store the range in per-instance Glance state (AGENDA_RANGE_KEY), read it reactively via currentState, and slice in the composition. The settings setter writes the state into each instance and recomposes, so a range change now reflects via plain recomposition regardless of session lifecycle. Co-Authored-By: Claude Opus 4.8 (1M context) --- .gitea/ISSUE_TEMPLATE/crash_report.md | 1 + .gitea/ISSUE_TEMPLATE/feature_request.md | 2 +- .gitea/ISSUE_TEMPLATE/question.md | 19 +++++++++++ .../ui/settings/SettingsViewModel.kt | 16 ++++++++- .../calendula/widget/WidgetData.kt | 21 ++++++++++-- .../calendula/widget/agenda/AgendaWidget.kt | 34 +++++++++++++++++-- 6 files changed, 85 insertions(+), 8 deletions(-) create mode 100644 .gitea/ISSUE_TEMPLATE/question.md diff --git a/.gitea/ISSUE_TEMPLATE/crash_report.md b/.gitea/ISSUE_TEMPLATE/crash_report.md index c76b1b7..6828788 100644 --- a/.gitea/ISSUE_TEMPLATE/crash_report.md +++ b/.gitea/ISSUE_TEMPLATE/crash_report.md @@ -5,6 +5,7 @@ title: "Crash: " labels: - bug - crash + - priority:high --- + + +### Context +- Calendula version: +- Android version: +- Device: 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 4adf344..b9516a6 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 @@ -2,6 +2,8 @@ package de.jeanlucmakiola.calendula.ui.settings import android.content.Context import android.os.Build +import androidx.glance.appwidget.GlanceAppWidgetManager +import androidx.glance.appwidget.state.updateAppWidgetState import androidx.glance.appwidget.updateAll import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope @@ -16,7 +18,9 @@ import de.jeanlucmakiola.calendula.data.prefs.WeekStartPref import de.jeanlucmakiola.calendula.domain.CalendarSource import de.jeanlucmakiola.calendula.domain.EventFormField 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.widget.agenda.AGENDA_RANGE_KEY import de.jeanlucmakiola.calendula.widget.agenda.AgendaWidget import de.jeanlucmakiola.calendula.widget.month.MonthWidget import kotlinx.coroutines.flow.Flow @@ -171,7 +175,17 @@ class SettingsViewModel @Inject constructor( fun setAgendaWidgetRange(range: AgendaRange) { viewModelScope.launch { prefs.setAgendaWidgetRange(range) - widgetRefreshMutex.withLock { AgendaWidget().updateAll(appContext) } + widgetRefreshMutex.withLock { + // Push the range into each instance's Glance state and recompose. + // The widget reads it via currentState, so this reflects reliably + // even when updateAll only recomposes a live session (it does not + // re-run provideGlance's data preamble). + val manager = GlanceAppWidgetManager(appContext) + manager.getGlanceIds(AgendaWidget::class.java).forEach { id -> + updateAppWidgetState(appContext, id) { it[AGENDA_RANGE_KEY] = range.storageValue() } + } + AgendaWidget().updateAll(appContext) + } } } 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 ac530cf..468939b 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/widget/WidgetData.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/widget/WidgetData.kt @@ -8,8 +8,8 @@ import de.jeanlucmakiola.calendula.data.prefs.is24Hour import de.jeanlucmakiola.calendula.data.prefs.resolveFirstDay import de.jeanlucmakiola.calendula.domain.EventInstance import de.jeanlucmakiola.calendula.ui.agenda.AgendaDay +import de.jeanlucmakiola.calendula.ui.agenda.AgendaRange import de.jeanlucmakiola.calendula.ui.agenda.agendaRange -import de.jeanlucmakiola.calendula.ui.agenda.dayCount import de.jeanlucmakiola.calendula.ui.agenda.groupAgendaDays import kotlinx.coroutines.flow.first import kotlinx.datetime.DateTimeUnit @@ -47,9 +47,19 @@ sealed interface AgendaWidgetData { data object NeedsPermission : AgendaWidgetData data class Ready( val today: LocalDate, + /** + * Days with events across the **widest** selectable window. The displayed + * range is sliced from this in the composition (read reactively from + * Glance state), so a range change is pure recomposition rather than a + * flaky widget session restart — the same approach the month widget uses. + */ val days: List, /** Resolved clock convention for event time labels (the time-format pref). */ val is24Hour: Boolean, + /** First day of the week, for the calendar-aligned "this week" range. */ + val weekStart: DayOfWeek, + /** Saved range pref — the fallback when an instance has no Glance state yet. */ + val savedRange: AgendaRange, ) : AgendaWidgetData } @@ -97,9 +107,12 @@ internal suspend fun Context.loadAgendaWidgetData(): AgendaWidgetData { val anchor = today(zone) val ep = widgetEntryPoint() val prefs = ep.settingsPrefs() - val range = prefs.agendaWidgetRange.first() + val savedRange = prefs.agendaWidgetRange.first() val weekStart = prefs.weekStart.first().resolveFirstDay(Locale.getDefault()) - val window = agendaRange(anchor, range.dayCount(anchor, weekStart) - 1, zone) + // Load the widest selectable window once; the displayed range is sliced in + // 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 instances = ep.calendarRepository().instances(window).first() val is24Hour = prefs.timeFormat.first() .is24Hour(android.text.format.DateFormat.is24HourFormat(this)) @@ -107,6 +120,8 @@ internal suspend fun Context.loadAgendaWidgetData(): AgendaWidgetData { today = anchor, days = groupAgendaDays(anchor, instances, zone), is24Hour = is24Hour, + weekStart = weekStart, + savedRange = savedRange, ) } 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 a2a28c3..4d5c77d 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 @@ -6,6 +6,7 @@ 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.stringPreferencesKey import androidx.glance.ColorFilter import androidx.glance.GlanceId import androidx.glance.GlanceModifier @@ -24,6 +25,8 @@ import androidx.glance.appwidget.lazy.items import androidx.glance.appwidget.provideContent import androidx.glance.appwidget.updateAll import androidx.glance.background +import androidx.glance.currentState +import androidx.glance.state.PreferencesGlanceStateDefinition import androidx.glance.layout.Alignment import androidx.glance.layout.Box import androidx.glance.layout.Column @@ -41,6 +44,9 @@ import androidx.glance.text.TextStyle import de.jeanlucmakiola.calendula.MainActivity import de.jeanlucmakiola.calendula.R import de.jeanlucmakiola.calendula.domain.EventInstance +import de.jeanlucmakiola.calendula.ui.agenda.AgendaRange +import de.jeanlucmakiola.calendula.ui.agenda.dayCount +import de.jeanlucmakiola.calendula.ui.agenda.parseAgendaRange import de.jeanlucmakiola.calendula.ui.common.CalendarView import de.jeanlucmakiola.calendula.ui.common.formatTimeOfDay import de.jeanlucmakiola.calendula.ui.common.pastelize @@ -63,8 +69,20 @@ import java.util.Locale * of events grouped under day headers (the Google "Schedule" widget model). * Reuses the app's [groupAgendaDays] grouping so it matches the in-app agenda. */ +/** + * Per-instance Glance state key holding the agenda range (as [AgendaRange.storageValue]). + * The range is read reactively in the composition ([currentState]) so a settings + * change is reflected by plain recomposition — `updateAll` does NOT reliably + * re-run the `provideGlance` preamble for a live widget session, so loading the + * range there and slicing it would silently keep the stale value (mirrors the + * month widget's MONTH_INDEX_KEY approach). + */ +internal val AGENDA_RANGE_KEY = stringPreferencesKey("agenda_range") + class AgendaWidget : GlanceAppWidget() { + override val stateDefinition = PreferencesGlanceStateDefinition + override suspend fun provideGlance(context: Context, id: GlanceId) { val data = context.loadAgendaWidgetData() val dark = (context.resources.configuration.uiMode and @@ -102,12 +120,21 @@ private fun AgendaWidgetBody(data: AgendaWidgetData, dark: Boolean) { Spacer(GlanceModifier.height(4.dp)) when (data) { AgendaWidgetData.NeedsPermission -> WidgetMessage(R.string.widget_needs_permission) - is AgendaWidgetData.Ready -> - if (data.days.isEmpty()) { + 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 + // pre-loaded window is sliced to it — pure recomposition. + val range = parseAgendaRange(currentState(AGENDA_RANGE_KEY), data.savedRange) + val rangeEnd = data.today.plus( + range.dayCount(data.today, data.weekStart) - 1, + DateTimeUnit.DAY, + ) + val visibleDays = data.days.filter { it.date <= rangeEnd } + if (visibleDays.isEmpty()) { WidgetMessage(R.string.agenda_empty_title) } else { val rows = buildList { - data.days.forEach { day -> + visibleDays.forEach { day -> add(AgendaRow.Header(day.date, data.today)) day.events.forEach { add(AgendaRow.Event(it)) } } @@ -121,6 +148,7 @@ private fun AgendaWidgetBody(data: AgendaWidgetData, dark: Boolean) { } } } + } } } } From 301904be6dbb04a06128c459c283802f67bad2ef Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Sat, 27 Jun 2026 17:11:29 +0200 Subject: [PATCH 18/26] docs(changelog): note agenda range bar and Appearance tidy-up in 2.11.0 Expand the #4 entry to cover the in-agenda range bar (date header + session range switch) and add a Changed note for the Appearance regrouping and the friendlier agenda empty state. Regenerate the F-Droid changelog. Co-Authored-By: Claude Opus 4.8 (1M context) --- CHANGELOG.md | 17 ++++++++++++----- .../metadata/android/en-US/changelogs/21100.txt | 17 ++++++++++++----- 2 files changed, 24 insertions(+), 10 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 307237d..174eef8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,11 +22,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 Appearance) draws a faint separator at each hour in the week and day views, making it easier to see when events start and end. Off by default. Thanks to @zmaherdev for the suggestion ([#5]). -- Limit how far ahead the agenda looks. New **Agenda range** and **Agenda widget - range** settings (Settings → Appearance) let the agenda screen and its widget - each show just today, the rest of this week, the rest of this month, a rolling - 7 or 30 days, or a custom number of days. "This week" follows your week-start - preference. Thanks to @zmaherdev for the suggestion ([#4]). +- Limit how far ahead the agenda looks, and switch it on the fly. New **Agenda + range** and **Agenda widget range** settings (Settings → Appearance) let the + agenda screen and its home-screen widget each show just today, the rest of this + week, the rest of this month, a rolling 7 or 30 days, or a custom number of + days — "This week" follows your week-start. The agenda also gains a bar at the + top naming the exact dates in view, with a button to switch the range just for + the current session (it resets when you reopen the app); the bar can be turned + off. Thanks to @zmaherdev for the suggestion ([#4]). + +### Changed +- Tidied **Settings → Appearance** into clearer groups (theme & colour, calendar, + agenda), and the agenda's empty state now reads "You're all caught up". ## [2.10.0] — 2026-06-25 diff --git a/fastlane/metadata/android/en-US/changelogs/21100.txt b/fastlane/metadata/android/en-US/changelogs/21100.txt index 7274b4f..52ecd4b 100644 --- a/fastlane/metadata/android/en-US/changelogs/21100.txt +++ b/fastlane/metadata/android/en-US/changelogs/21100.txt @@ -13,9 +13,16 @@ Appearance) draws a faint separator at each hour in the week and day views, making it easier to see when events start and end. Off by default. Thanks to @zmaherdev for the suggestion ([#5]). -- Limit how far ahead the agenda looks. New **Agenda range** and **Agenda widget - range** settings (Settings → Appearance) let the agenda screen and its widget - each show just today, the rest of this week, the rest of this month, a rolling - 7 or 30 days, or a custom number of days. "This week" follows your week-start - preference. Thanks to @zmaherdev for the suggestion ([#4]). +- Limit how far ahead the agenda looks, and switch it on the fly. New **Agenda + range** and **Agenda widget range** settings (Settings → Appearance) let the + agenda screen and its home-screen widget each show just today, the rest of this + week, the rest of this month, a rolling 7 or 30 days, or a custom number of + days — "This week" follows your week-start. The agenda also gains a bar at the + top naming the exact dates in view, with a button to switch the range just for + the current session (it resets when you reopen the app); the bar can be turned + off. Thanks to @zmaherdev for the suggestion ([#4]). + +### Changed +- Tidied **Settings → Appearance** into clearer groups (theme & colour, calendar, + agenda), and the agenda's empty state now reads "You're all caught up". From e6736b049a9bebc88853baf122e4684f669d6c94 Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Sat, 27 Jun 2026 20:39:07 +0200 Subject: [PATCH 19/26] feat(settings): settings UX polish + Calendars manager redesign MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Settings: - Event-form field rows show each field's own icon (shared EventFormFieldVisuals, reused by the editor and settings) - Move "Add Quick Settings tile" to a top-level Settings hub row - Notifications: reliable-delivery + snooze moved above the per-calendar block, which now folds behind one expandable section Calendars manager: - Local and synced calendars now use one collapsible group card - Source-branded headers: each account shows its app's launcher icon (Google Calendar, DAVx5, …) loaded from PackageManager; local shows a device chip - Per-account toggle-all switch (CalendarsViewModel.setAccountDisabled) - Management action ("Add calendar" / "Manage in app") is a nested row inside the expanded group; removed the dead "Add account" row - A fully deactivated account dims its header, not just the switch - Group headers use the normal row colour; their options sit one tone darker (GroupedRow gains an optional container colour) Co-Authored-By: Claude Opus 4.8 (1M context) --- .../calendula/ui/calendars/CalendarsScreen.kt | 319 ++++++++++++------ .../ui/calendars/CalendarsViewModel.kt | 13 + .../ui/common/EventFormFieldVisuals.kt | 44 +++ .../calendula/ui/common/GroupedList.kt | 9 +- .../calendula/ui/edit/EventEditScreen.kt | 29 +- .../calendula/ui/settings/SettingsScreen.kt | 213 +++++++----- app/src/main/res/values/strings.xml | 3 + 7 files changed, 410 insertions(+), 220 deletions(-) create mode 100644 app/src/main/java/de/jeanlucmakiola/calendula/ui/common/EventFormFieldVisuals.kt diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/calendars/CalendarsScreen.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/calendars/CalendarsScreen.kt index db9dfde..5fe402f 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/calendars/CalendarsScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/calendars/CalendarsScreen.kt @@ -6,11 +6,14 @@ import android.content.Intent import android.provider.Settings import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.foundation.Image import androidx.compose.foundation.background import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ColumnScope import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize @@ -26,20 +29,21 @@ import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.automirrored.filled.Notes +import androidx.compose.material.icons.automirrored.filled.OpenInNew import androidx.compose.material.icons.filled.Add import androidx.compose.material.icons.filled.CalendarMonth import androidx.compose.material.icons.filled.Close +import androidx.compose.material.icons.filled.Cloud import androidx.compose.material.icons.filled.Delete import androidx.compose.material.icons.filled.FileDownload -import androidx.compose.material.icons.filled.OpenInNew import androidx.compose.material.icons.filled.Palette +import androidx.compose.material.icons.filled.PhoneAndroid import androidx.compose.material3.AlertDialog import androidx.compose.material3.Button import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.OutlinedButton import androidx.compose.material3.Scaffold import androidx.compose.material3.SnackbarHost import androidx.compose.material3.SnackbarHostState @@ -61,20 +65,25 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.draw.alpha import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.ImageBitmap +import androidx.compose.ui.graphics.asImageBitmap import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.compose.ui.semantics.contentDescription import androidx.compose.ui.semantics.semantics -import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.input.KeyboardCapitalization import androidx.compose.ui.unit.dp +import androidx.core.graphics.drawable.toBitmap import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle import de.jeanlucmakiola.calendula.R import de.jeanlucmakiola.calendula.domain.CalendarSource import de.jeanlucmakiola.calendula.ui.common.CALENDAR_COLOR_PALETTE import de.jeanlucmakiola.calendula.ui.common.CalendarColorChip +import de.jeanlucmakiola.calendula.ui.common.calendarCollapseExit +import de.jeanlucmakiola.calendula.ui.common.calendarExpandEnter import de.jeanlucmakiola.calendula.ui.common.predictiveBack import de.jeanlucmakiola.calendula.ui.common.CollapsingScaffold import de.jeanlucmakiola.calendula.ui.common.ColorSwatchRow @@ -82,7 +91,6 @@ import de.jeanlucmakiola.calendula.ui.common.GroupedRow import de.jeanlucmakiola.calendula.ui.common.InlineTextField import de.jeanlucmakiola.calendula.ui.common.Position import de.jeanlucmakiola.calendula.ui.common.pastelize -import de.jeanlucmakiola.calendula.ui.common.positionOf import java.time.LocalDate /** Sentinel [editorId] meaning "the editor is composing a new calendar". */ @@ -148,6 +156,7 @@ fun CalendarsScreen( onAdd = { editorSession++; editorId = NEW_CALENDAR_ID }, onEdit = { calendar -> editorSession++; editorId = calendar.id }, onSetDisabled = viewModel::setDisabled, + onSetAccountDisabled = viewModel::setAccountDisabled, ) } } @@ -166,9 +175,14 @@ private fun CalendarsList( onAdd: () -> Unit, onEdit: (CalendarSource) -> Unit, onSetDisabled: (Long, Boolean) -> Unit, + onSetAccountDisabled: (Collection, Boolean) -> Unit, ) { val context = LocalContext.current val snackbarHostState = remember { SnackbarHostState() } + // Accounts the user has folded shut; empty = all expanded (keeps every + // calendar visible by default, the section is collapsible for tidiness). + var collapsedAccounts by remember { mutableStateOf(emptySet()) } + var localExpanded by remember { mutableStateOf(true) } val writeErrorText = stringResource(R.string.calendars_write_error) LaunchedEffect(error) { @@ -208,38 +222,55 @@ private fun CalendarsList( onBack = onBack, snackbarHost = { SnackbarHost(snackbarHostState) }, ) { - // Local (device-only) calendars — the calendars the app owns. The - // "Add calendar" entry closes the group as its final row. - SectionHeader(stringResource(R.string.calendars_local_header)) - if (local.isEmpty()) { - HintText(stringResource(R.string.calendars_local_empty)) - } + // What the per-calendar / per-account switches below actually do. HintText(stringResource(R.string.calendars_disable_hint)) - val localCount = local.size + 1 - local.forEachIndexed { index, calendar -> - val disabled = calendar.id in disabledIds - GroupedRow( - title = calendar.displayName, - summary = calendar.description, - position = positionOf(index, localCount), - dimmed = disabled, - leading = { CalendarColorChip(calendar.color, dimIf(disabled)) }, - trailing = { - EnableSwitch( - calendarName = calendar.displayName, - enabled = !disabled, - onToggle = { enabled -> onSetDisabled(calendar.id, !enabled) }, + + // Local (device-only) calendars — one collapsible group. The header's + // "+" adds a calendar; the switch enables/disables them all at once; + // tapping a calendar row opens its editor. + val localDisabled = local.isNotEmpty() && local.all { it.id in disabledIds } + CalendarGroup( + title = stringResource(R.string.calendars_local_header), + expanded = localExpanded, + headerDisabled = localDisabled, + leading = { Box(dimIf(localDisabled)) { LeadingAvatar(Icons.Default.PhoneAndroid) } }, + manageIcon = Icons.Default.Add, + manageLabel = stringResource(R.string.calendars_add), + onManage = onAdd, + manageConnectsBelow = local.isNotEmpty(), + onToggleExpand = { localExpanded = !localExpanded }, + showToggleAll = local.isNotEmpty(), + allEnabled = local.none { it.id in disabledIds }, + toggleAllLabel = stringResource( + R.string.calendars_account_toggle_all_a11y, + stringResource(R.string.calendars_local_header), + ), + onToggleAll = { enabled -> onSetAccountDisabled(local.map { it.id }, !enabled) }, + ) { + if (local.isEmpty()) { + HintText(stringResource(R.string.calendars_local_empty)) + } else { + local.forEachIndexed { index, calendar -> + val disabled = calendar.id in disabledIds + GroupedRow( + title = calendar.displayName, + summary = calendar.description, + position = if (index == local.lastIndex) Position.Bottom else Position.Middle, + container = MaterialTheme.colorScheme.surfaceContainerHighest, + dimmed = disabled, + leading = { CalendarColorChip(calendar.color, dimIf(disabled)) }, + trailing = { + EnableSwitch( + calendarName = calendar.displayName, + enabled = !disabled, + onToggle = { enabled -> onSetDisabled(calendar.id, !enabled) }, + ) + }, + onClick = { onEdit(calendar) }, ) - }, - onClick = { onEdit(calendar) }, - ) + } + } } - GroupedRow( - title = stringResource(R.string.calendars_add), - position = positionOf(local.size, localCount), - leading = { AddAvatar() }, - onClick = onAdd, - ) // Backup — local calendars have no sync, so a .ics export is their only // safety net. Offered only when there is something to back up. @@ -259,40 +290,60 @@ private fun CalendarsList( Spacer(Modifier.height(16.dp)) - // Synced calendars — read-only, grouped by account, each with a - // per-account "manage in source app" link. + // Synced calendars — read-only, grouped by account. Each account is a + // collapsible group whose header opens the source app (icon) and toggles + // all of its calendars at once (switch). SectionHeader(stringResource(R.string.calendars_synced_header)) HintText(stringResource(R.string.calendars_synced_hint)) synced .groupBy { it.accountName.ifBlank { it.accountType } } .forEach { (account, cals) -> - AccountHeader(account = account, accountType = cals.first().accountType) - cals.forEachIndexed { index, calendar -> - val disabled = calendar.id in disabledIds - GroupedRow( - title = calendar.displayName, - position = positionOf(index, cals.size), - dimmed = disabled, - leading = { CalendarColorChip(calendar.color, dimIf(disabled)) }, - trailing = { - EnableSwitch( - calendarName = calendar.displayName, - enabled = !disabled, - onToggle = { enabled -> onSetDisabled(calendar.id, !enabled) }, - ) - }, - ) + val expanded = account !in collapsedAccounts + val accountType = cals.first().accountType + val accountDisabled = cals.all { it.id in disabledIds } + Spacer(Modifier.height(16.dp)) + CalendarGroup( + title = account, + expanded = expanded, + headerDisabled = accountDisabled, + leading = { Box(dimIf(accountDisabled)) { SourceLogo(accountType) } }, + manageIcon = Icons.AutoMirrored.Filled.OpenInNew, + manageLabel = stringResource(R.string.calendars_manage_account_a11y, account), + onManage = { + runCatching { context.startActivity(sourceAppIntent(context, accountType)) } + }, + manageConnectsBelow = true, + onToggleExpand = { + collapsedAccounts = if (expanded) { + collapsedAccounts + account + } else { + collapsedAccounts - account + } + }, + showToggleAll = true, + allEnabled = cals.none { it.id in disabledIds }, + toggleAllLabel = stringResource(R.string.calendars_account_toggle_all_a11y, account), + onToggleAll = { enabled -> onSetAccountDisabled(cals.map { it.id }, !enabled) }, + ) { + cals.forEachIndexed { index, calendar -> + val disabled = calendar.id in disabledIds + GroupedRow( + title = calendar.displayName, + position = if (index == cals.lastIndex) Position.Bottom else Position.Middle, + container = MaterialTheme.colorScheme.surfaceContainerHighest, + dimmed = disabled, + leading = { CalendarColorChip(calendar.color, dimIf(disabled)) }, + trailing = { + EnableSwitch( + calendarName = calendar.displayName, + enabled = !disabled, + onToggle = { enabled -> onSetDisabled(calendar.id, !enabled) }, + ) + }, + ) + } } } - Spacer(Modifier.height(8.dp)) - GroupedRow( - title = stringResource(R.string.calendars_add_account), - position = Position.Alone, - leading = { AddAvatar() }, - onClick = { - runCatching { context.startActivity(Intent(Settings.ACTION_ADD_ACCOUNT)) } - }, - ) } } @@ -498,32 +549,121 @@ private fun EditorCard( } } +/** + * One collapsible calendar group rendered as a connected card. The header row is + * the card's top (tap to expand/collapse): a [leading] source mark (the account + * app's logo, or a device chip for local), the group [title] and — when + * [showToggleAll] — a "toggle all" [Switch] that enables or disables every + * calendar in the group at once. + * + * When expanded, the body opens with a differentiated management row + * ([manageIcon] / [manageLabel] → [onManage]: add a calendar, or open the source + * app) above the plain calendar rows supplied by [body]; [manageConnectsBelow] + * is false when no calendar rows follow it, so it rounds off as the card's foot. + * + * An active group gets a tinted header; once [headerDisabled] (every calendar in + * the group switched off) the header drops to a plain, dimmed row so the whole + * group reads as off — not just its switch. + */ @Composable -private fun AccountHeader(account: String, accountType: String) { - val context = LocalContext.current - Row( - modifier = Modifier - .fillMaxWidth() - .padding(start = 28.dp, end = 16.dp, top = 16.dp, bottom = 4.dp), - verticalAlignment = Alignment.CenterVertically, +private fun CalendarGroup( + title: String, + expanded: Boolean, + headerDisabled: Boolean, + leading: @Composable () -> Unit, + manageIcon: ImageVector, + manageLabel: String, + onManage: () -> Unit, + manageConnectsBelow: Boolean, + onToggleExpand: () -> Unit, + showToggleAll: Boolean, + allEnabled: Boolean, + toggleAllLabel: String, + onToggleAll: (Boolean) -> Unit, + body: @Composable ColumnScope.() -> Unit, +) { + GroupedRow( + title = title, + position = if (expanded) Position.Top else Position.Alone, + // The account header keeps the standard row colour; the options it reveals + // sit one tone darker, so the group reads as a header over nested content. + dimmed = headerDisabled, + leading = leading, + trailing = if (showToggleAll) { + { + Switch( + checked = allEnabled, + onCheckedChange = onToggleAll, + modifier = Modifier.semantics { contentDescription = toggleAllLabel }, + ) + } + } else { + null + }, + onClick = onToggleExpand, + ) + AnimatedVisibility( + visible = expanded, + enter = calendarExpandEnter(), + exit = calendarCollapseExit(), ) { - Text( - text = account, - style = MaterialTheme.typography.titleSmall, - fontWeight = FontWeight.SemiBold, - modifier = Modifier.weight(1f), - ) - OutlinedButton(onClick = { - runCatching { context.startActivity(sourceAppIntent(context, accountType)) } - }) { - Icon(Icons.Default.OpenInNew, contentDescription = null, modifier = Modifier.size(16.dp)) - Spacer(Modifier.width(6.dp)) - Text(stringResource(R.string.calendars_manage_in_app)) + Column { + GroupedRow( + title = manageLabel, + position = if (manageConnectsBelow) Position.Middle else Position.Bottom, + container = MaterialTheme.colorScheme.surfaceContainerHighest, + dimmed = headerDisabled, + leading = { Icon(manageIcon, contentDescription = null) }, + onClick = onManage, + ) + body() } } } -/** Neutral circular chip carrying an arbitrary icon — matches [AddAvatar]'s shape. */ +/** + * The source app's launcher icon (Google Calendar, DAVx5, Nextcloud, …) as a + * round 40dp chip, so each synced account is recognisable at a glance. We load + * whatever app owns the account from [PackageManager] rather than bundling brand + * logos — always accurate, nothing to license. Falls back to a neutral cloud + * chip when no installed app resolves for the account. + */ +@Composable +private fun SourceLogo(accountType: String) { + val context = LocalContext.current + val logo = remember(accountType) { sourceAppLogo(context, accountType) } + if (logo != null) { + Image( + bitmap = logo, + contentDescription = null, + contentScale = ContentScale.Crop, + modifier = Modifier + .size(40.dp) + .clip(CircleShape), + ) + } else { + LeadingAvatar(Icons.Default.Cloud) + } +} + +/** The launcher icon of the app backing [accountType], preferring the human-facing app. */ +private fun sourceAppLogo(context: Context, accountType: String): ImageBitmap? { + val pm = context.packageManager + val candidates = buildList { + curatedSourcePackage(accountType)?.let { add(it) } + AccountManager.get(context).authenticatorTypes + .firstOrNull { it.type.equals(accountType, ignoreCase = true) } + ?.packageName + ?.let { add(it) } + } + for (pkg in candidates) { + val bitmap = runCatching { pm.getApplicationIcon(pkg).toBitmap() }.getOrNull() + if (bitmap != null) return bitmap.asImageBitmap() + } + return null +} + +/** Neutral circular chip carrying an arbitrary icon (e.g. the local-device mark). */ @Composable private fun LeadingAvatar(icon: ImageVector) { Box( @@ -542,25 +682,6 @@ private fun LeadingAvatar(icon: ImageVector) { } } -/** Neutral circular chip with a "+" — the leading icon for add-actions. */ -@Composable -private fun AddAvatar() { - Box( - modifier = Modifier - .size(40.dp) - .clip(CircleShape) - .background(MaterialTheme.colorScheme.surfaceContainerHighest), - contentAlignment = Alignment.Center, - ) { - Icon( - Icons.Default.Add, - contentDescription = null, - tint = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.size(22.dp), - ) - } -} - @Composable private fun SectionHeader(text: String) { Text( diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/calendars/CalendarsViewModel.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/calendars/CalendarsViewModel.kt index 17451f7..2d1e1b8 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/calendars/CalendarsViewModel.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/calendars/CalendarsViewModel.kt @@ -123,6 +123,19 @@ class CalendarsViewModel @Inject constructor( } } + /** + * Enable or disable every calendar of one account in a single write — the + * "toggle all" affordance on an account header. Done as one set update so the + * per-calendar [setDisabled] calls can't race each other. + */ + fun setAccountDisabled(ids: Collection, disabled: Boolean) { + viewModelScope.launch { + val current = prefs.disabledCalendarIds.first() + val next = if (disabled) current + ids else current - ids.toSet() + if (next != current) prefs.setDisabledCalendarIds(next) + } + } + private inline fun write(crossinline block: suspend () -> Unit) { viewModelScope.launch { try { 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 new file mode 100644 index 0000000..aa07543 --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/EventFormFieldVisuals.kt @@ -0,0 +1,44 @@ +package de.jeanlucmakiola.calendula.ui.common + +import androidx.annotation.StringRes +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.Notes +import androidx.compose.material.icons.filled.EventAvailable +import androidx.compose.material.icons.filled.Lock +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.Repeat +import androidx.compose.ui.graphics.vector.ImageVector +import de.jeanlucmakiola.calendula.R +import de.jeanlucmakiola.calendula.domain.EventFormField + +/** + * The shared label and icon for each optional [EventFormField]. Both the event + * editor (which shows these fields) and the settings screen (which toggles their + * defaults) draw from this one mapping, so a field reads the same wherever it + * appears. + */ +@StringRes +fun eventFormFieldLabel(field: EventFormField): Int = when (field) { + EventFormField.Location -> R.string.event_detail_location + EventFormField.Description -> R.string.event_detail_description + EventFormField.Reminders -> R.string.event_detail_reminders + EventFormField.Recurrence -> R.string.event_detail_recurrence + EventFormField.Availability -> R.string.event_edit_availability + EventFormField.Visibility -> R.string.event_edit_visibility + EventFormField.Color -> R.string.event_edit_color + EventFormField.Attendees -> R.string.event_edit_attendees +} + +fun eventFormFieldIcon(field: EventFormField): ImageVector = when (field) { + EventFormField.Location -> Icons.Default.Place + EventFormField.Description -> Icons.AutoMirrored.Filled.Notes + EventFormField.Reminders -> Icons.Default.Notifications + EventFormField.Recurrence -> Icons.Default.Repeat + EventFormField.Availability -> Icons.Default.EventAvailable + EventFormField.Visibility -> Icons.Default.Lock + EventFormField.Color -> Icons.Default.Palette + EventFormField.Attendees -> Icons.Default.People +} diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/GroupedList.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/GroupedList.kt index 7479753..c183aab 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/GroupedList.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/GroupedList.kt @@ -140,6 +140,7 @@ fun GroupedRow( summary: String? = null, selected: Boolean = false, dimmed: Boolean = false, + container: Color? = null, minHeight: Dp = 72.dp, leading: @Composable (() -> Unit)? = null, trailing: @Composable (() -> Unit)? = null, @@ -198,10 +199,10 @@ fun GroupedRow( .fillMaxWidth() .padding(horizontal = 16.dp) .then(gap) - val containerColor = if (selected) { - MaterialTheme.colorScheme.secondaryContainer - } else { - MaterialTheme.colorScheme.surfaceContainerHigh + val containerColor = when { + selected -> MaterialTheme.colorScheme.secondaryContainer + container != null -> container + else -> MaterialTheme.colorScheme.surfaceContainerHigh } if (onClick != null) { Surface( 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 853edfe..22d5ac6 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 @@ -43,7 +43,6 @@ import androidx.compose.material.icons.filled.EventAvailable import androidx.compose.material.icons.filled.Lock 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.Person import androidx.compose.material.icons.filled.PersonAdd import androidx.compose.material.icons.filled.Place @@ -123,6 +122,8 @@ import de.jeanlucmakiola.calendula.ui.common.CalendarDatePickerDialog import de.jeanlucmakiola.calendula.ui.common.ColorSwatchRow import de.jeanlucmakiola.calendula.ui.common.DialogAmountField import de.jeanlucmakiola.calendula.ui.common.DialogUnitDropdown +import de.jeanlucmakiola.calendula.ui.common.eventFormFieldIcon +import de.jeanlucmakiola.calendula.ui.common.eventFormFieldLabel import de.jeanlucmakiola.calendula.ui.common.MILLIS_PER_DAY import de.jeanlucmakiola.calendula.ui.common.InlineTextField import de.jeanlucmakiola.calendula.ui.common.OptionCard @@ -1019,9 +1020,9 @@ private fun FieldPickerDialog( Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { hiddenFields.forEach { field -> OptionCard( - label = stringResource(fieldLabel(field)), + label = stringResource(eventFormFieldLabel(field)), onClick = { onSelect(field) }, - icon = fieldIcon(field), + icon = eventFormFieldIcon(field), ) } } @@ -1638,28 +1639,6 @@ private fun readContactAddress(context: Context, uri: Uri): String? = } -private fun fieldLabel(field: EventFormField): Int = when (field) { - EventFormField.Location -> R.string.event_detail_location - EventFormField.Description -> R.string.event_detail_description - EventFormField.Reminders -> R.string.event_detail_reminders - EventFormField.Recurrence -> R.string.event_detail_recurrence - EventFormField.Availability -> R.string.event_edit_availability - EventFormField.Visibility -> R.string.event_edit_visibility - EventFormField.Color -> R.string.event_edit_color - EventFormField.Attendees -> R.string.event_edit_attendees -} - -private fun fieldIcon(field: EventFormField): ImageVector = when (field) { - EventFormField.Location -> Icons.Default.Place - EventFormField.Description -> Icons.AutoMirrored.Filled.Notes - EventFormField.Reminders -> Icons.Default.Notifications - EventFormField.Recurrence -> Icons.Default.Repeat - EventFormField.Availability -> Icons.Default.EventAvailable - EventFormField.Visibility -> Icons.Default.Lock - EventFormField.Color -> Icons.Default.Palette - EventFormField.Attendees -> Icons.Default.People -} - /** * Visibility selector: one card per level, each with its own icon; the * current level is highlighted. Tap picks and closes. 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 5dc6ef8..dab5958 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 @@ -39,6 +39,7 @@ import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.BugReport 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.ExpandLess import androidx.compose.material.icons.filled.ExpandMore @@ -109,6 +110,8 @@ import de.jeanlucmakiola.calendula.ui.common.positionOf import de.jeanlucmakiola.calendula.ui.common.reminderLeadTimeLabel import de.jeanlucmakiola.calendula.ui.common.rememberCalendarSlideSpec import de.jeanlucmakiola.calendula.ui.common.currentLocale +import de.jeanlucmakiola.calendula.ui.common.eventFormFieldIcon +import de.jeanlucmakiola.calendula.ui.common.eventFormFieldLabel import kotlinx.datetime.DayOfWeek import kotlinx.datetime.LocalTime import java.time.format.TextStyle as JavaTextStyle @@ -220,6 +223,12 @@ private fun SettingsHub( onClick = onManageCalendars, ) LanguageRow(position = Position.Middle) + // One-tap add of the "New event" Quick Settings tile. The system prompt + // is API 33+; on older versions the tile is still addable manually from + // the QS editor, so the row simply doesn't appear here. + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + QuickSettingsTileRow(position = Position.Middle) + } ReportProblemRow(position = Position.Bottom) AppVersionText() @@ -261,6 +270,24 @@ private fun ReportProblemRow(position: Position) { } } +/** + * Asks the system to add the "New event" Quick Settings tile (API 33+). A + * top-level action row, like [ReportProblemRow] — the tile is a system-surface + * shortcut, not part of any one settings category. + */ +@RequiresApi(Build.VERSION_CODES.TIRAMISU) +@Composable +private fun QuickSettingsTileRow(position: Position) { + val context = LocalContext.current + GroupedRow( + title = stringResource(R.string.settings_qs_tile), + summary = stringResource(R.string.settings_qs_tile_hint), + position = position, + leading = { CategoryIcon(Icons.Default.Dashboard, ChipAccent.Neutral) }, + onClick = { requestAddQsTile(context) }, + ) +} + @Composable private fun LanguageRow(position: Position) { val context = LocalContext.current @@ -612,8 +639,17 @@ private fun EventFormScreen( fields.forEachIndexed { index, field -> val checked = field in state.defaultFormFields GroupedRow( - title = stringResource(formFieldLabel(field)), + title = stringResource(eventFormFieldLabel(field)), position = positionOf(index, fields.size), + // Same icon the field carries in the new-event form, so a toggle + // is easy to match to the field it controls. + leading = { + Icon( + imageVector = eventFormFieldIcon(field), + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + }, trailing = { Switch( checked = checked, @@ -644,20 +680,6 @@ private fun EventFormScreen( ) }, ) - - // One-tap add of the "New event" Quick Settings tile. The system prompt - // is API 33+; on older versions the tile is still addable manually from - // the QS editor, so the row simply doesn't appear there. - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { - Spacer(Modifier.height(24.dp)) - val context = LocalContext.current - GroupedRow( - title = stringResource(R.string.settings_qs_tile), - summary = stringResource(R.string.settings_qs_tile_hint), - position = Position.Alone, - onClick = { requestAddQsTile(context) }, - ) - } } } @@ -709,6 +731,7 @@ private fun NotificationsScreen( var showAllDayReminderTime by remember { mutableStateOf(false) } var showSnooze by remember { mutableStateOf(false) } var overrideDialog by remember { mutableStateOf(null) } + var calendarSectionExpanded by remember { mutableStateOf(false) } var expandedCalendars by remember { mutableStateOf(emptySet()) } CollapsingScaffold( @@ -746,69 +769,11 @@ private fun NotificationsScreen( onClick = { showAllDayReminderTime = true }, ) - // Per-calendar overrides: each writable calendar may keep, drop, or - // replace the global default — separately for timed and all-day events. - if (state.writableCalendars.isNotEmpty()) { - Spacer(Modifier.height(24.dp)) - Text( - text = stringResource(R.string.settings_calendar_reminders_hint), - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.padding(horizontal = 16.dp, vertical = 4.dp), - ) - state.writableCalendars.forEach { calendar -> - Spacer(Modifier.height(16.dp)) - val expanded = calendar.id in expandedCalendars - // Calendar card; tapping expands it into a grouped list of three - // (the card + the timed and all-day override rows). - GroupedRow( - title = calendar.displayName, - position = if (expanded) Position.Top else Position.Alone, - leading = { CalendarColorChip(calendar.color) }, - trailing = { - Icon( - imageVector = if (expanded) Icons.Default.ExpandLess else Icons.Default.ExpandMore, - contentDescription = null, - tint = MaterialTheme.colorScheme.onSurfaceVariant, - ) - }, - onClick = { - expandedCalendars = if (expanded) { - expandedCalendars - calendar.id - } else { - expandedCalendars + calendar.id - } - }, - ) - AnimatedVisibility( - visible = expanded, - enter = calendarExpandEnter(), - exit = calendarCollapseExit(), - ) { - Column { - val timed = state.perCalendarReminderOverride.choiceFor(calendar.id) - GroupedRow( - title = stringResource(R.string.settings_default_reminder), - summary = calendarOverrideSummary(timed, state.defaultReminderMinutes), - position = Position.Middle, - onClick = { overrideDialog = OverrideTarget(calendar.id, isAllDay = false) }, - ) - val allDay = state.perCalendarAllDayReminderOverride.choiceFor(calendar.id) - GroupedRow( - title = stringResource(R.string.settings_default_reminder_allday), - summary = calendarOverrideSummary(allDay, state.defaultAllDayReminderMinutes), - position = Position.Bottom, - onClick = { overrideDialog = OverrideTarget(calendar.id, isAllDay = true) }, - ) - } - } - } - } - - // Delivery reliability: Android's battery optimisation can delay or drop - // the calendar provider's reminder broadcast. A soft, optional exemption - // (system-settings deep-link, no special permission) improves on-time - // delivery; shown as live status, reversible by the user at any time. + // Delivery reliability + snooze: both are global reminder-delivery + // settings, so they sit with the defaults above rather than below the + // long per-calendar list. Reliability is a soft, optional battery- + // optimisation exemption (system-settings deep-link, no special + // permission); shown as live status, reversible at any time. Spacer(Modifier.height(24.dp)) val batteryExempt = rememberBatteryOptimizationExempt() GroupedRow( @@ -818,7 +783,7 @@ private fun NotificationsScreen( } else { stringResource(R.string.settings_reliable_delivery_hint) }, - position = Position.Alone, + position = Position.Top, trailing = if (batteryExempt) { { Icon( @@ -834,13 +799,88 @@ private fun NotificationsScreen( ) // Snooze: how long the notification's "Snooze" action defers a reminder. - Spacer(Modifier.height(24.dp)) GroupedRow( title = stringResource(R.string.settings_snooze_duration), summary = snoozeDurationLabel(state.snoozeMinutes), - position = Position.Alone, + position = Position.Bottom, onClick = { showSnooze = true }, ) + + // Per-calendar overrides: the whole section folds behind one header to + // keep the screen tidy. Expanded, each writable calendar gets its own + // expandable card that may keep, drop, or replace the global default — + // separately for timed and all-day events. + if (state.writableCalendars.isNotEmpty()) { + Spacer(Modifier.height(24.dp)) + GroupedRow( + title = stringResource(R.string.settings_calendar_reminders_title), + summary = stringResource(R.string.settings_calendar_reminders_hint), + position = Position.Alone, + trailing = { + Icon( + imageVector = if (calendarSectionExpanded) Icons.Default.ExpandLess else Icons.Default.ExpandMore, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + }, + onClick = { calendarSectionExpanded = !calendarSectionExpanded }, + ) + AnimatedVisibility( + visible = calendarSectionExpanded, + enter = calendarExpandEnter(), + exit = calendarCollapseExit(), + ) { + Column { + state.writableCalendars.forEach { calendar -> + Spacer(Modifier.height(16.dp)) + val expanded = calendar.id in expandedCalendars + // Calendar card; tapping expands it into a grouped list + // of three (the card + the timed and all-day rows). + GroupedRow( + title = calendar.displayName, + position = if (expanded) Position.Top else Position.Alone, + leading = { CalendarColorChip(calendar.color) }, + trailing = { + Icon( + imageVector = if (expanded) Icons.Default.ExpandLess else Icons.Default.ExpandMore, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + }, + onClick = { + expandedCalendars = if (expanded) { + expandedCalendars - calendar.id + } else { + expandedCalendars + calendar.id + } + }, + ) + AnimatedVisibility( + visible = expanded, + enter = calendarExpandEnter(), + exit = calendarCollapseExit(), + ) { + Column { + val timed = state.perCalendarReminderOverride.choiceFor(calendar.id) + GroupedRow( + title = stringResource(R.string.settings_default_reminder), + summary = calendarOverrideSummary(timed, state.defaultReminderMinutes), + position = Position.Middle, + onClick = { overrideDialog = OverrideTarget(calendar.id, isAllDay = false) }, + ) + val allDay = state.perCalendarAllDayReminderOverride.choiceFor(calendar.id) + GroupedRow( + title = stringResource(R.string.settings_default_reminder_allday), + summary = calendarOverrideSummary(allDay, state.defaultAllDayReminderMinutes), + position = Position.Bottom, + onClick = { overrideDialog = OverrideTarget(calendar.id, isAllDay = true) }, + ) + } + } + } + } + } + } } if (showSnooze) { @@ -1061,17 +1101,6 @@ private fun openUrl(context: Context, url: String) { runCatching { context.startActivity(intent) } } -private fun formFieldLabel(field: EventFormField): Int = when (field) { - EventFormField.Location -> R.string.event_detail_location - EventFormField.Description -> R.string.event_detail_description - EventFormField.Reminders -> R.string.event_detail_reminders - EventFormField.Recurrence -> R.string.event_detail_recurrence - EventFormField.Availability -> R.string.event_edit_availability - EventFormField.Visibility -> R.string.event_edit_visibility - EventFormField.Color -> R.string.event_edit_color - EventFormField.Attendees -> R.string.event_edit_attendees -} - @Composable private fun themeLabel(mode: ThemeMode): String = stringResource( when (mode) { diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 60c1872..f12a4aa 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -309,6 +309,7 @@ Amount Custom (%1$s) Set + Per-calendar reminders Override the default per calendar — separately for timed and all-day events. A calendar can keep the default, drop it, or set its own. Default (%1$s) Reliable delivery @@ -343,9 +344,11 @@ Add calendar Turn a calendar off to remove it from the app — its events, filters and pickers. Nothing is deleted, and you can turn it back on here anytime. Show \"%1$s\" in the app + Show all calendars from %1$s in the app Synced calendars These come from accounts on your device. Create and edit them in their own app. Manage + Manage %1$s in its app Add account New calendar Edit calendar From 614f4f2d75291d0e3e048bf3c48ba1ae76ae916c Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Sat, 27 Jun 2026 21:27:20 +0200 Subject: [PATCH 20/26] feat(calendars): account header overflow menu + restyle MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the per-account toggle switch and nested manage row with a single trailing overflow (⋮) menu holding both account-level actions: - "Enable all" / "Disable all" — toggle every calendar in the group - "Manage in app" (synced) / "Add calendar" (local) The dropdown is styled to fit: rounded corners, a distinct floating surface (surfaceContainerLowest + lifted shadow) so it stands clear of the cards, and a divider separating the two actions. Shortened the manage label to "Manage in app" and dropped the now-unused account a11y strings. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../calendula/ui/calendars/CalendarsScreen.kt | 144 ++++++++++++------ app/src/main/res/values/strings.xml | 7 +- 2 files changed, 105 insertions(+), 46 deletions(-) diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/calendars/CalendarsScreen.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/calendars/CalendarsScreen.kt index 5fe402f..6ae8fba 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/calendars/CalendarsScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/calendars/CalendarsScreen.kt @@ -36,11 +36,17 @@ import androidx.compose.material.icons.filled.Close import androidx.compose.material.icons.filled.Cloud import androidx.compose.material.icons.filled.Delete import androidx.compose.material.icons.filled.FileDownload +import androidx.compose.material.icons.filled.MoreVert import androidx.compose.material.icons.filled.Palette import androidx.compose.material.icons.filled.PhoneAndroid +import androidx.compose.material.icons.filled.Visibility +import androidx.compose.material.icons.filled.VisibilityOff import androidx.compose.material3.AlertDialog import androidx.compose.material3.Button +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem 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 @@ -232,19 +238,15 @@ private fun CalendarsList( CalendarGroup( title = stringResource(R.string.calendars_local_header), expanded = localExpanded, + bodyHasRows = local.isNotEmpty(), headerDisabled = localDisabled, leading = { Box(dimIf(localDisabled)) { LeadingAvatar(Icons.Default.PhoneAndroid) } }, manageIcon = Icons.Default.Add, manageLabel = stringResource(R.string.calendars_add), onManage = onAdd, - manageConnectsBelow = local.isNotEmpty(), onToggleExpand = { localExpanded = !localExpanded }, showToggleAll = local.isNotEmpty(), allEnabled = local.none { it.id in disabledIds }, - toggleAllLabel = stringResource( - R.string.calendars_account_toggle_all_a11y, - stringResource(R.string.calendars_local_header), - ), onToggleAll = { enabled -> onSetAccountDisabled(local.map { it.id }, !enabled) }, ) { if (local.isEmpty()) { @@ -305,14 +307,14 @@ private fun CalendarsList( CalendarGroup( title = account, expanded = expanded, + bodyHasRows = true, headerDisabled = accountDisabled, leading = { Box(dimIf(accountDisabled)) { SourceLogo(accountType) } }, manageIcon = Icons.AutoMirrored.Filled.OpenInNew, - manageLabel = stringResource(R.string.calendars_manage_account_a11y, account), + manageLabel = stringResource(R.string.calendars_manage_in_app), onManage = { runCatching { context.startActivity(sourceAppIntent(context, accountType)) } }, - manageConnectsBelow = true, onToggleExpand = { collapsedAccounts = if (expanded) { collapsedAccounts + account @@ -322,7 +324,6 @@ private fun CalendarsList( }, showToggleAll = true, allEnabled = cals.none { it.id in disabledIds }, - toggleAllLabel = stringResource(R.string.calendars_account_toggle_all_a11y, account), onToggleAll = { enabled -> onSetAccountDisabled(cals.map { it.id }, !enabled) }, ) { cals.forEachIndexed { index, calendar -> @@ -552,53 +553,50 @@ private fun EditorCard( /** * One collapsible calendar group rendered as a connected card. The header row is * the card's top (tap to expand/collapse): a [leading] source mark (the account - * app's logo, or a device chip for local), the group [title] and — when - * [showToggleAll] — a "toggle all" [Switch] that enables or disables every - * calendar in the group at once. + * app's logo, or a device chip for local), the group [title] and a trailing + * overflow (⋮) menu holding the account-level actions — enable/disable every + * calendar at once (when [showToggleAll]) and the manage/add action + * ([manageIcon] / [manageLabel] → [onManage]). The group's calendars render as + * the rows below via [body]. * - * When expanded, the body opens with a differentiated management row - * ([manageIcon] / [manageLabel] → [onManage]: add a calendar, or open the source - * app) above the plain calendar rows supplied by [body]; [manageConnectsBelow] - * is false when no calendar rows follow it, so it rounds off as the card's foot. - * - * An active group gets a tinted header; once [headerDisabled] (every calendar in - * the group switched off) the header drops to a plain, dimmed row so the whole - * group reads as off — not just its switch. + * The header keeps the standard row colour; the calendars it reveals sit one tone + * darker, so the group reads as a header over nested content. Once [headerDisabled] + * (every calendar switched off) the header fades to the disabled emphasis so the + * whole group reads as off — not just via the menu. [bodyHasRows] is false when no + * calendar rows follow (e.g. an empty local group), so the header stays a + * standalone card rather than a top edge with nothing beneath it. */ @Composable private fun CalendarGroup( title: String, expanded: Boolean, + bodyHasRows: Boolean, headerDisabled: Boolean, leading: @Composable () -> Unit, manageIcon: ImageVector, manageLabel: String, onManage: () -> Unit, - manageConnectsBelow: Boolean, onToggleExpand: () -> Unit, showToggleAll: Boolean, allEnabled: Boolean, - toggleAllLabel: String, onToggleAll: (Boolean) -> Unit, body: @Composable ColumnScope.() -> Unit, ) { GroupedRow( title = title, - position = if (expanded) Position.Top else Position.Alone, - // The account header keeps the standard row colour; the options it reveals - // sit one tone darker, so the group reads as a header over nested content. + position = if (expanded && bodyHasRows) Position.Top else Position.Alone, dimmed = headerDisabled, leading = leading, - trailing = if (showToggleAll) { - { - Switch( - checked = allEnabled, - onCheckedChange = onToggleAll, - modifier = Modifier.semantics { contentDescription = toggleAllLabel }, - ) - } - } else { - null + trailing = { + CalendarGroupMenu( + title = title, + showToggleAll = showToggleAll, + allEnabled = allEnabled, + onToggleAll = onToggleAll, + manageIcon = manageIcon, + manageLabel = manageLabel, + onManage = onManage, + ) }, onClick = onToggleExpand, ) @@ -607,16 +605,76 @@ private fun CalendarGroup( enter = calendarExpandEnter(), exit = calendarCollapseExit(), ) { - Column { - GroupedRow( - title = manageLabel, - position = if (manageConnectsBelow) Position.Middle else Position.Bottom, - container = MaterialTheme.colorScheme.surfaceContainerHighest, - dimmed = headerDisabled, - leading = { Icon(manageIcon, contentDescription = null) }, - onClick = onManage, + Column(content = body) + } +} + +/** + * The account header's overflow (⋮) menu: the two account-level actions that + * don't fit on one line — "Enable/Disable all" (when [showToggleAll]) and the + * manage/add action. A rounded, tonal dropdown matching the app's surfaces, with + * the two actions divided for clear separation. + */ +@Composable +private fun CalendarGroupMenu( + title: String, + showToggleAll: Boolean, + allEnabled: Boolean, + onToggleAll: (Boolean) -> Unit, + manageIcon: ImageVector, + manageLabel: String, + onManage: () -> Unit, +) { + var open by remember { mutableStateOf(false) } + Box { + IconButton(onClick = { open = true }) { + Icon( + Icons.Default.MoreVert, + contentDescription = stringResource(R.string.calendars_account_menu_a11y, title), + ) + } + DropdownMenu( + expanded = open, + onDismissRequest = { open = false }, + shape = RoundedCornerShape(20.dp), + // A distinct tone + a lifted shadow so the menu reads as floating + // above the cards (which sit at surfaceContainerHigh) rather than + // blending into them. + containerColor = MaterialTheme.colorScheme.surfaceContainerLowest, + tonalElevation = 0.dp, + shadowElevation = 6.dp, + ) { + if (showToggleAll) { + DropdownMenuItem( + text = { + Text( + stringResource( + if (allEnabled) R.string.calendars_disable_all + else R.string.calendars_enable_all, + ), + ) + }, + leadingIcon = { + Icon( + if (allEnabled) Icons.Default.VisibilityOff else Icons.Default.Visibility, + contentDescription = null, + ) + }, + onClick = { + open = false + onToggleAll(!allEnabled) + }, + ) + HorizontalDivider(Modifier.padding(horizontal = 12.dp, vertical = 4.dp)) + } + DropdownMenuItem( + text = { Text(manageLabel) }, + leadingIcon = { Icon(manageIcon, contentDescription = null) }, + onClick = { + open = false + onManage() + }, ) - body() } } } diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index f12a4aa..fdc17d3 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -344,11 +344,12 @@ Add calendar Turn a calendar off to remove it from the app — its events, filters and pickers. Nothing is deleted, and you can turn it back on here anytime. Show \"%1$s\" in the app - Show all calendars from %1$s in the app Synced calendars These come from accounts on your device. Create and edit them in their own app. - Manage - Manage %1$s in its app + Manage in app + More options for %1$s + Enable all + Disable all Add account New calendar Edit calendar From 548e58155461e1cd7b1b95f922d691c22dcc9f7b Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Sat, 27 Jun 2026 22:13:55 +0200 Subject: [PATCH 21/26] feat(backup): automatic periodic .ics export of local calendars (#8) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Scheduled one-way export of local calendars to a user-chosen folder, overwriting calendula-backup.ics each run — the manual backup, automated. Not a sync; stays INTERNET-free (provider reads + local file write). - WorkManager periodic job (deps: work-runtime-ktx, documentfile); worker pulls collaborators via a Hilt @EntryPoint, so no custom WorkerFactory wiring. First periodic run is delayed one interval so it can't race the immediate "run now" feedback run; the writer also overwrites the canonical file and cleans up any "(1)" duplicates from earlier races. - SettingsPrefs: enabled, interval (minutes, floored at 30), folder Uri, last-run status; persisted SAF write grant. - UI in Calendars > Backup: toggle, folder picker, amount+unit interval dialog, last-run status line. Notifies after repeated failures. Co-Authored-By: Claude Opus 4.8 (1M context) --- app/build.gradle.kts | 3 + .../calendula/data/backup/AutoBackup.kt | 170 ++++++++++++++++++ .../calendula/data/ics/IcsExporter.kt | 32 ++++ .../calendula/data/prefs/SettingsPrefs.kt | 65 +++++++ .../calendula/ui/calendars/CalendarsScreen.kt | 152 ++++++++++++++++ .../ui/calendars/CalendarsViewModel.kt | 77 ++++++++ app/src/main/res/values/strings.xml | 28 +++ gradle/libs.versions.toml | 4 + 8 files changed, 531 insertions(+) create mode 100644 app/src/main/java/de/jeanlucmakiola/calendula/data/backup/AutoBackup.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 7c9966c..46440cf 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -152,6 +152,9 @@ dependencies { implementation(libs.androidx.datastore.preferences) + implementation(libs.androidx.work.runtime.ktx) + implementation(libs.androidx.documentfile) + implementation(libs.androidx.glance.appwidget) implementation(libs.androidx.glance.material3) diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/data/backup/AutoBackup.kt b/app/src/main/java/de/jeanlucmakiola/calendula/data/backup/AutoBackup.kt new file mode 100644 index 0000000..c08fa72 --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/calendula/data/backup/AutoBackup.kt @@ -0,0 +1,170 @@ +package de.jeanlucmakiola.calendula.data.backup + +import android.Manifest +import android.app.NotificationChannel +import android.app.NotificationManager +import android.app.PendingIntent +import android.content.Context +import android.content.pm.PackageManager +import android.os.Build +import android.util.Log +import androidx.core.app.NotificationCompat +import androidx.core.app.NotificationManagerCompat +import androidx.core.content.ContextCompat +import androidx.core.net.toUri +import androidx.work.CoroutineWorker +import androidx.work.ExistingPeriodicWorkPolicy +import androidx.work.ExistingWorkPolicy +import androidx.work.OneTimeWorkRequestBuilder +import androidx.work.PeriodicWorkRequestBuilder +import androidx.work.WorkManager +import androidx.work.WorkerParameters +import dagger.hilt.EntryPoint +import dagger.hilt.InstallIn +import dagger.hilt.android.EntryPointAccessors +import dagger.hilt.components.SingletonComponent +import de.jeanlucmakiola.calendula.R +import de.jeanlucmakiola.calendula.data.calendar.CalendarRepository +import de.jeanlucmakiola.calendula.data.ics.IcsExporter +import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs +import de.jeanlucmakiola.calendula.domain.ics.IcsWriter +import kotlinx.coroutines.flow.first +import java.util.concurrent.TimeUnit +import kotlin.time.Clock + +/** + * Schedules and runs the automatic periodic `.ics` backup of local calendars + * (issue #8). One-way export only — it mirrors the manual "Back up" into a + * user-chosen folder on an interval; it is not a sync. Stays INTERNET-free: + * everything is local provider reads + a local file write. + */ +object BackupScheduler { + + private const val WORK_NAME = "auto-backup" + private const val WORK_NAME_NOW = "auto-backup-now" + + /** + * Reconcile the scheduled work with the current settings: enqueue a unique + * periodic request when backup is on and a folder is set, otherwise cancel + * it. WorkManager persists the request across reboots on its own. + * + * The first run is delayed by one interval so it never overlaps an immediate + * [runNow] (two simultaneous writes would race and leave a "(1)" duplicate). + */ + fun apply(context: Context, enabled: Boolean, intervalMinutes: Long, hasFolder: Boolean) { + val workManager = WorkManager.getInstance(context) + if (!enabled || !hasFolder) { + workManager.cancelUniqueWork(WORK_NAME) + return + } + // WorkManager's own floor is 15 min; the UI floors the user choice at 30. + val interval = intervalMinutes.coerceAtLeast(15L) + val request = PeriodicWorkRequestBuilder(interval, TimeUnit.MINUTES) + .setInitialDelay(interval, TimeUnit.MINUTES) + .build() + workManager.enqueueUniquePeriodicWork( + WORK_NAME, + ExistingPeriodicWorkPolicy.UPDATE, + request, + ) + } + + /** + * Run one export immediately (e.g. right after enabling or changing the + * folder) for instant feedback. Unique + REPLACE so rapid taps coalesce into + * a single run rather than racing each other. + */ + fun runNow(context: Context) { + WorkManager.getInstance(context).enqueueUniqueWork( + WORK_NAME_NOW, + ExistingWorkPolicy.REPLACE, + OneTimeWorkRequestBuilder().build(), + ) + } +} + +/** + * Exports the local calendars to `calendula-backup.ics` in the configured folder, + * overwriting the previous file. Pulls its collaborators through a Hilt + * [EntryPoint] so it works under WorkManager's default (no-arg) worker factory — + * no custom factory / Application wiring needed. Records the outcome for the + * settings status line, and notifies after repeated failures. + */ +class BackupWorker( + appContext: Context, + params: WorkerParameters, +) : CoroutineWorker(appContext, params) { + + @EntryPoint + @InstallIn(SingletonComponent::class) + interface Deps { + fun settingsPrefs(): SettingsPrefs + fun repository(): CalendarRepository + fun exporter(): IcsExporter + } + + override suspend fun doWork(): Result { + val deps = EntryPointAccessors.fromApplication(applicationContext, Deps::class.java) + val prefs = deps.settingsPrefs() + val folder = prefs.autoBackupFolderUri.first() + ?: return Result.failure() // nothing to write to — leave scheduling to settings + val now = System.currentTimeMillis() + return try { + val events = deps.repository().exportEvents() + val content = IcsWriter().writeCalendar(events, Clock.System.now()) + deps.exporter().writeToFolder(folder.toUri(), BACKUP_FILE_NAME, content) + prefs.recordAutoBackupRun(success = true, atMillis = now) + Result.success() + } catch (e: Exception) { + prefs.recordAutoBackupRun(success = false, atMillis = now) + if (prefs.autoBackupStatus.first().consecutiveFailures >= FAILURE_NOTIFY_THRESHOLD) { + notifyFailure(applicationContext) + } + Log.w(TAG, "Automatic backup failed", e) + Result.retry() + } + } + + private fun notifyFailure(context: Context) { + val canPost = Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU || + ContextCompat.checkSelfPermission(context, Manifest.permission.POST_NOTIFICATIONS) == + PackageManager.PERMISSION_GRANTED + if (!canPost) return + val manager = NotificationManagerCompat.from(context) + manager.createNotificationChannel( + NotificationChannel( + CHANNEL_ID, + context.getString(R.string.backup_channel_name), + NotificationManager.IMPORTANCE_LOW, + ).apply { description = context.getString(R.string.backup_channel_description) }, + ) + val launch = context.packageManager.getLaunchIntentForPackage(context.packageName) + val tap = launch?.let { + PendingIntent.getActivity( + context, 0, it, + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE, + ) + } + val notification = NotificationCompat.Builder(context, CHANNEL_ID) + .setSmallIcon(R.drawable.ic_notification) + .setContentTitle(context.getString(R.string.backup_failed_title)) + .setContentText(context.getString(R.string.backup_failed_text)) + .setCategory(NotificationCompat.CATEGORY_ERROR) + .setAutoCancel(true) + .apply { tap?.let(::setContentIntent) } + .build() + try { + manager.notify(NOTIFICATION_ID, notification) + } catch (e: SecurityException) { + Log.w(TAG, "Could not post backup-failure notification", e) + } + } + + companion object { + const val BACKUP_FILE_NAME = "calendula-backup.ics" + private const val FAILURE_NOTIFY_THRESHOLD = 2 + private const val CHANNEL_ID = "backup" + private const val NOTIFICATION_ID = 2 + private const val TAG = "BackupWorker" + } +} diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/data/ics/IcsExporter.kt b/app/src/main/java/de/jeanlucmakiola/calendula/data/ics/IcsExporter.kt index 70b0f9a..5ad0967 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/data/ics/IcsExporter.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/data/ics/IcsExporter.kt @@ -3,6 +3,7 @@ package de.jeanlucmakiola.calendula.data.ics import android.content.Context import android.net.Uri import androidx.core.content.FileProvider +import androidx.documentfile.provider.DocumentFile import dagger.hilt.android.qualifiers.ApplicationContext import java.io.File import java.io.IOException @@ -28,6 +29,36 @@ class IcsExporter @Inject constructor( } ?: throw IOException("Could not open output stream for export (scheme=${uri.scheme})") } + /** + * Write [content] to [fileName] inside the persisted SAF tree [folder], + * overwriting it if it already exists (the automatic-backup destination). + * Requires a persisted write grant on [folder]. Throws on failure. + */ + fun writeToFolder(folder: Uri, fileName: String, content: String) { + val dir = DocumentFile.fromTreeUri(context, folder) + ?: throw IOException("Backup folder is not accessible") + if (!dir.exists() || !dir.canWrite()) { + throw IOException("Backup folder is missing or not writable") + } + // Reuse the canonical file if present and clean up any "name (1).ics" + // duplicates SAF may have created if two runs ever raced — so we always + // converge on a single overwritten file. + val base = fileName.substringBeforeLast('.') + val ext = fileName.substringAfterLast('.', "") + var target: DocumentFile? = null + for (child in dir.listFiles()) { + val name = child.name ?: continue + when { + name == fileName -> target = child + name.startsWith("$base (") && name.endsWith(".$ext") -> child.delete() + } + } + val file = target + ?: dir.createFile(MIME_CALENDAR, fileName) + ?: throw IOException("Could not create backup file in the chosen folder") + writeDocument(file.uri, content) + } + /** * Stage [content] in a private cache file and return a shareable content * Uri for an `ACTION_SEND`. [fileName] is the suggested `.ics` name shown to @@ -42,5 +73,6 @@ class IcsExporter @Inject constructor( private companion object { const val SHARE_DIR = "shared_ics" + const val MIME_CALENDAR = "text/calendar" } } 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 ef2c757..0fec684 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 @@ -5,6 +5,7 @@ import androidx.datastore.preferences.core.Preferences import androidx.datastore.preferences.core.booleanPreferencesKey import androidx.datastore.preferences.core.edit import androidx.datastore.preferences.core.intPreferencesKey +import androidx.datastore.preferences.core.longPreferencesKey import androidx.datastore.preferences.core.stringPreferencesKey import de.jeanlucmakiola.calendula.domain.EventFormField import de.jeanlucmakiola.calendula.ui.agenda.AgendaRange @@ -327,6 +328,52 @@ class SettingsPrefs @Inject constructor( } } + // --- Automatic backup (issue #8) ------------------------------------ + + /** Whether periodic automatic `.ics` export of local calendars is on. Default OFF. */ + val autoBackupEnabled: Flow = store.data.map { it[AUTO_BACKUP_ENABLED_KEY] ?: false } + + suspend fun setAutoBackupEnabled(enabled: Boolean) { + store.edit { it[AUTO_BACKUP_ENABLED_KEY] = enabled } + } + + /** Export interval in minutes. Default daily; floored at [MIN_BACKUP_INTERVAL]. */ + val autoBackupIntervalMinutes: Flow = store.data.map { prefs -> + (prefs[AUTO_BACKUP_INTERVAL_KEY] ?: DEFAULT_BACKUP_INTERVAL).coerceAtLeast(MIN_BACKUP_INTERVAL) + } + + suspend fun setAutoBackupIntervalMinutes(minutes: Long) { + store.edit { it[AUTO_BACKUP_INTERVAL_KEY] = minutes.coerceAtLeast(MIN_BACKUP_INTERVAL) } + } + + /** Persisted SAF tree Uri of the destination folder, or null if unset. */ + val autoBackupFolderUri: Flow = store.data.map { it[AUTO_BACKUP_FOLDER_KEY] } + + suspend fun setAutoBackupFolderUri(uri: String?) { + store.edit { prefs -> + if (uri == null) prefs.remove(AUTO_BACKUP_FOLDER_KEY) else prefs[AUTO_BACKUP_FOLDER_KEY] = uri + } + } + + /** Outcome of the last automatic run, for the settings status line. */ + val autoBackupStatus: Flow = store.data.map { prefs -> + BackupStatus( + lastRun = prefs[AUTO_BACKUP_LAST_RUN_KEY] ?: 0L, + lastSuccess = prefs[AUTO_BACKUP_LAST_SUCCESS_KEY] ?: true, + consecutiveFailures = prefs[AUTO_BACKUP_FAILURES_KEY] ?: 0, + ) + } + + /** Record an automatic run's outcome; resets the failure streak on success. */ + suspend fun recordAutoBackupRun(success: Boolean, atMillis: Long) { + store.edit { prefs -> + prefs[AUTO_BACKUP_LAST_RUN_KEY] = atMillis + prefs[AUTO_BACKUP_LAST_SUCCESS_KEY] = success + val failures = prefs[AUTO_BACKUP_FAILURES_KEY] ?: 0 + prefs[AUTO_BACKUP_FAILURES_KEY] = if (success) 0 else failures + 1 + } + } + private fun parseFormFields(stored: String?): Set = when (stored) { null -> DEFAULT_FORM_FIELDS else -> stored.split(',') @@ -366,9 +413,27 @@ class SettingsPrefs @Inject constructor( stringPreferencesKey("per_calendar_allday_reminder_override") internal val DEFAULT_FORM_FIELDS = setOf(EventFormField.Location, EventFormField.Description) + 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") + internal val AUTO_BACKUP_LAST_RUN_KEY = longPreferencesKey("auto_backup_last_run") + internal val AUTO_BACKUP_LAST_SUCCESS_KEY = booleanPreferencesKey("auto_backup_last_success") + internal val AUTO_BACKUP_FAILURES_KEY = intPreferencesKey("auto_backup_failures") + /** Default automatic-backup interval: daily. */ + const val DEFAULT_BACKUP_INTERVAL = 1_440L + /** Floor for the automatic-backup interval (also above WorkManager's 15-min limit). */ + const val MIN_BACKUP_INTERVAL = 30L } } +/** Snapshot of the automatic backup's last outcome (see [SettingsPrefs.autoBackupStatus]). */ +data class BackupStatus( + /** Epoch millis of the last run, or 0 if it has never run. */ + val lastRun: Long, + val lastSuccess: Boolean, + val consecutiveFailures: Int, +) + /** A calendar's reminder-default override (see [SettingsPrefs.perCalendarReminderOverride]). */ sealed interface CalendarReminderOverride { /** No override — the calendar uses the global default. */ diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/calendars/CalendarsScreen.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/calendars/CalendarsScreen.kt index 6ae8fba..eeb4086 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/calendars/CalendarsScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/calendars/CalendarsScreen.kt @@ -4,6 +4,7 @@ import android.accounts.AccountManager import android.content.Context import android.content.Intent import android.provider.Settings +import android.text.format.DateUtils import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.animation.AnimatedVisibility @@ -39,6 +40,7 @@ import androidx.compose.material.icons.filled.FileDownload import androidx.compose.material.icons.filled.MoreVert import androidx.compose.material.icons.filled.Palette import androidx.compose.material.icons.filled.PhoneAndroid +import androidx.compose.material.icons.filled.Schedule import androidx.compose.material.icons.filled.Visibility import androidx.compose.material.icons.filled.VisibilityOff import androidx.compose.material3.AlertDialog @@ -76,18 +78,26 @@ import androidx.compose.ui.graphics.asImageBitmap import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.pluralStringResource +import androidx.compose.ui.res.stringArrayResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.semantics.contentDescription import androidx.compose.ui.semantics.semantics import androidx.compose.ui.text.input.KeyboardCapitalization import androidx.compose.ui.unit.dp import androidx.core.graphics.drawable.toBitmap +import androidx.core.net.toUri +import androidx.documentfile.provider.DocumentFile import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle import de.jeanlucmakiola.calendula.R +import de.jeanlucmakiola.calendula.data.prefs.BackupStatus +import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs import de.jeanlucmakiola.calendula.domain.CalendarSource import de.jeanlucmakiola.calendula.ui.common.CALENDAR_COLOR_PALETTE import de.jeanlucmakiola.calendula.ui.common.CalendarColorChip +import de.jeanlucmakiola.calendula.ui.common.DialogAmountField +import de.jeanlucmakiola.calendula.ui.common.DialogUnitDropdown import de.jeanlucmakiola.calendula.ui.common.calendarCollapseExit import de.jeanlucmakiola.calendula.ui.common.calendarExpandEnter import de.jeanlucmakiola.calendula.ui.common.predictiveBack @@ -118,6 +128,7 @@ fun CalendarsScreen( val disabledIds by viewModel.disabledCalendarIds.collectAsStateWithLifecycle() val error by viewModel.error.collectAsStateWithLifecycle() val backupResult by viewModel.backupResult.collectAsStateWithLifecycle() + val autoBackup by viewModel.autoBackup.collectAsStateWithLifecycle() // null = list; NEW_CALENDAR_ID = create; any other id = edit that calendar. // [editorSession] bumps on every open so the editor's field state resets for @@ -158,6 +169,10 @@ fun CalendarsScreen( backupResult = backupResult, onExportBackup = viewModel::exportBackup, onConsumeBackupResult = viewModel::consumeBackupResult, + autoBackup = autoBackup, + onSetAutoBackupEnabled = viewModel::setAutoBackupEnabled, + onSetAutoBackupInterval = viewModel::setAutoBackupIntervalMinutes, + onSetAutoBackupFolder = viewModel::setAutoBackupFolder, onBack = onBack, onAdd = { editorSession++; editorId = NEW_CALENDAR_ID }, onEdit = { calendar -> editorSession++; editorId = calendar.id }, @@ -177,6 +192,10 @@ private fun CalendarsList( backupResult: BackupResult?, onExportBackup: (android.net.Uri) -> Unit, onConsumeBackupResult: () -> Unit, + autoBackup: AutoBackupUiState, + onSetAutoBackupEnabled: (Boolean) -> Unit, + onSetAutoBackupInterval: (Long) -> Unit, + onSetAutoBackupFolder: (android.net.Uri) -> Unit, onBack: () -> Unit, onAdd: () -> Unit, onEdit: (CalendarSource) -> Unit, @@ -204,6 +223,13 @@ private fun CalendarsList( contract = ActivityResultContracts.CreateDocument("text/calendar"), ) { uri -> uri?.let(onExportBackup) } + // SAF folder picker for the automatic-backup destination; the VM persists the + // write grant so background runs can keep writing to it. + val pickFolder = rememberLauncherForActivityResult( + contract = ActivityResultContracts.OpenDocumentTree(), + ) { uri -> uri?.let(onSetAutoBackupFolder) } + var showInterval by remember { mutableStateOf(false) } + val backupFailedText = stringResource(R.string.calendars_backup_failed) LaunchedEffect(backupResult) { when (val r = backupResult) { @@ -288,6 +314,35 @@ private fun CalendarsList( runCatching { createBackup.launch("calendula-backup-${LocalDate.now()}.ics") } }, ) + + // Automatic periodic export to a chosen folder (issue #8). + Spacer(Modifier.height(16.dp)) + GroupedRow( + title = stringResource(R.string.calendars_auto_backup), + summary = stringResource(R.string.calendars_auto_backup_hint), + position = if (autoBackup.enabled) Position.Top else Position.Alone, + leading = { LeadingAvatar(Icons.Default.Schedule) }, + trailing = { + Switch(checked = autoBackup.enabled, onCheckedChange = onSetAutoBackupEnabled) + }, + onClick = { onSetAutoBackupEnabled(!autoBackup.enabled) }, + ) + if (autoBackup.enabled) { + GroupedRow( + title = stringResource(R.string.calendars_auto_backup_folder), + summary = rememberFolderName(autoBackup.folderUri) + ?: stringResource(R.string.calendars_auto_backup_folder_unset), + position = Position.Middle, + onClick = { runCatching { pickFolder.launch(null) } }, + ) + GroupedRow( + title = stringResource(R.string.calendars_auto_backup_interval), + summary = backupIntervalLabel(autoBackup.intervalMinutes), + position = Position.Bottom, + onClick = { showInterval = true }, + ) + HintText(backupStatusText(autoBackup.status)) + } } Spacer(Modifier.height(16.dp)) @@ -346,6 +401,14 @@ private fun CalendarsList( } } } + + if (showInterval) { + BackupIntervalDialog( + currentMinutes = autoBackup.intervalMinutes, + onConfirm = onSetAutoBackupInterval, + onDismiss = { showInterval = false }, + ) + } } @OptIn(ExperimentalMaterial3Api::class) @@ -760,6 +823,95 @@ private fun HintText(text: String) { ) } +/** Readable name of the persisted backup folder, resolved from its tree Uri. */ +@Composable +private fun rememberFolderName(uriString: String?): String? { + val context = LocalContext.current + return remember(uriString) { + uriString?.let { + runCatching { DocumentFile.fromTreeUri(context, it.toUri())?.name }.getOrNull() + } + } +} + +/** "Every 30 minutes" / "Every 2 hours" / "Every day" — the interval in its largest whole unit. */ +@Composable +private fun backupIntervalLabel(minutes: Long): String { + val duration = when { + minutes % MINUTES_PER_WEEK == 0L -> + pluralStringResource(R.plurals.duration_weeks, (minutes / MINUTES_PER_WEEK).toInt(), (minutes / MINUTES_PER_WEEK).toInt()) + minutes % MINUTES_PER_DAY == 0L -> + pluralStringResource(R.plurals.duration_days, (minutes / MINUTES_PER_DAY).toInt(), (minutes / MINUTES_PER_DAY).toInt()) + minutes % 60L == 0L -> + pluralStringResource(R.plurals.duration_hours, (minutes / 60L).toInt(), (minutes / 60L).toInt()) + else -> + pluralStringResource(R.plurals.duration_minutes, minutes.toInt(), minutes.toInt()) + } + return stringResource(R.string.calendars_auto_backup_every, duration) +} + +/** "Last backup: 5 minutes ago" / "… failed" / "No automatic backup yet". */ +@Composable +private fun backupStatusText(status: BackupStatus): String { + if (status.lastRun == 0L) return stringResource(R.string.calendars_auto_backup_status_never) + val relative = DateUtils.getRelativeTimeSpanString( + status.lastRun, System.currentTimeMillis(), DateUtils.MINUTE_IN_MILLIS, + ).toString() + return if (status.lastSuccess) { + stringResource(R.string.calendars_auto_backup_status_ok, relative) + } else { + stringResource(R.string.calendars_auto_backup_status_failed, relative) + } +} + +/** Amount + unit picker for the backup interval (floored at 30 minutes). */ +@Composable +private fun BackupIntervalDialog( + currentMinutes: Long, + onConfirm: (Long) -> Unit, + onDismiss: () -> Unit, +) { + // minutes-per-unit for each entry; pick the largest unit the current value divides into. + val unitMinutes = remember { listOf(1L, 60L, MINUTES_PER_DAY, MINUTES_PER_WEEK) } + val units = stringArrayResource(R.array.backup_interval_units).toList() + val initialUnit = unitMinutes.indexOfLast { currentMinutes % it == 0L }.coerceAtLeast(0) + var amount by rememberSaveable { mutableStateOf((currentMinutes / unitMinutes[initialUnit]).toString()) } + var unitIndex by rememberSaveable { mutableStateOf(initialUnit) } + + AlertDialog( + onDismissRequest = onDismiss, + title = { Text(stringResource(R.string.calendars_auto_backup_interval)) }, + text = { + Column { + Row(verticalAlignment = Alignment.CenterVertically) { + DialogAmountField(value = amount, onValueChange = { amount = it }, placeholder = "1") + Spacer(Modifier.width(12.dp)) + DialogUnitDropdown(label = units[unitIndex], entries = units) { unitIndex = it } + } + Spacer(Modifier.height(8.dp)) + Text( + text = stringResource(R.string.calendars_auto_backup_interval_min), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + }, + confirmButton = { + TextButton(onClick = { + val value = amount.toLongOrNull()?.coerceAtLeast(1L) ?: 1L + onConfirm((value * unitMinutes[unitIndex]).coerceAtLeast(SettingsPrefs.MIN_BACKUP_INTERVAL)) + onDismiss() + }) { Text(stringResource(R.string.reminder_custom_set)) } + }, + dismissButton = { + TextButton(onClick = onDismiss) { Text(stringResource(R.string.dialog_cancel)) } + }, + ) +} + +private const val MINUTES_PER_DAY = 1_440L +private const val MINUTES_PER_WEEK = 10_080L + /** * Pick the app to open for managing a synced calendar's account. The account's * own authenticator package (resolved from [AccountManager], no permission diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/calendars/CalendarsViewModel.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/calendars/CalendarsViewModel.kt index 2d1e1b8..d56ee9a 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/calendars/CalendarsViewModel.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/calendars/CalendarsViewModel.kt @@ -1,13 +1,19 @@ package de.jeanlucmakiola.calendula.ui.calendars +import android.content.Context +import android.content.Intent import android.net.Uri import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import dagger.hilt.android.lifecycle.HiltViewModel +import dagger.hilt.android.qualifiers.ApplicationContext +import de.jeanlucmakiola.calendula.data.backup.BackupScheduler import de.jeanlucmakiola.calendula.data.calendar.CalendarRepository import de.jeanlucmakiola.calendula.data.di.IoDispatcher import de.jeanlucmakiola.calendula.data.ics.IcsExporter +import de.jeanlucmakiola.calendula.data.prefs.BackupStatus import de.jeanlucmakiola.calendula.data.prefs.CalendarPrefs +import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs import de.jeanlucmakiola.calendula.domain.CalendarSource import de.jeanlucmakiola.calendula.domain.ics.IcsWriter import kotlinx.coroutines.CoroutineDispatcher @@ -16,6 +22,7 @@ import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.catch +import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.flow.stateIn @@ -33,9 +40,11 @@ import javax.inject.Inject */ @HiltViewModel class CalendarsViewModel @Inject constructor( + @ApplicationContext private val context: Context, private val repository: CalendarRepository, private val icsExporter: IcsExporter, private val prefs: CalendarPrefs, + private val settingsPrefs: SettingsPrefs, @IoDispatcher private val io: CoroutineDispatcher, ) : ViewModel() { @@ -63,6 +72,22 @@ class CalendarsViewModel @Inject constructor( initialValue = emptySet(), ) + /** Automatic-backup settings + last-run status, for the Backup section UI. */ + val autoBackup: StateFlow = combine( + settingsPrefs.autoBackupEnabled, + settingsPrefs.autoBackupIntervalMinutes, + settingsPrefs.autoBackupFolderUri, + settingsPrefs.autoBackupStatus, + ) { enabled, interval, folder, status -> + AutoBackupUiState(enabled, interval, folder, status) + } + .flowOn(io) + .stateIn( + scope = viewModelScope, + started = SharingStarted.WhileSubscribed(5_000L), + initialValue = AutoBackupUiState(), + ) + private val _error = MutableStateFlow(false) val error: StateFlow = _error.asStateFlow() @@ -136,6 +161,50 @@ class CalendarsViewModel @Inject constructor( } } + // --- Automatic backup (issue #8) ------------------------------------ + + fun setAutoBackupEnabled(enabled: Boolean) { + viewModelScope.launch { + settingsPrefs.setAutoBackupEnabled(enabled) + reschedule() + // Give immediate feedback when turning it on with a folder already set. + if (enabled && settingsPrefs.autoBackupFolderUri.first() != null) { + BackupScheduler.runNow(context) + } + } + } + + fun setAutoBackupIntervalMinutes(minutes: Long) { + viewModelScope.launch { + settingsPrefs.setAutoBackupIntervalMinutes(minutes) + reschedule() + } + } + + /** Persist the chosen destination folder (taking a durable write grant) and run once. */ + fun setAutoBackupFolder(uri: Uri) { + viewModelScope.launch { + runCatching { + context.contentResolver.takePersistableUriPermission( + uri, + Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION, + ) + } + settingsPrefs.setAutoBackupFolderUri(uri.toString()) + reschedule() + if (settingsPrefs.autoBackupEnabled.first()) BackupScheduler.runNow(context) + } + } + + private suspend fun reschedule() { + BackupScheduler.apply( + context = context, + enabled = settingsPrefs.autoBackupEnabled.first(), + intervalMinutes = settingsPrefs.autoBackupIntervalMinutes.first(), + hasFolder = settingsPrefs.autoBackupFolderUri.first() != null, + ) + } + private inline fun write(crossinline block: suspend () -> Unit) { viewModelScope.launch { try { @@ -149,6 +218,14 @@ class CalendarsViewModel @Inject constructor( } } +/** Automatic-backup settings + last-run status for the Backup section. */ +data class AutoBackupUiState( + val enabled: Boolean = false, + val intervalMinutes: Long = SettingsPrefs.DEFAULT_BACKUP_INTERVAL, + val folderUri: String? = null, + val status: BackupStatus = BackupStatus(lastRun = 0L, lastSuccess = true, consecutiveFailures = 0), +) + /** Outcome of a whole-calendar backup, surfaced once to the screen. */ sealed interface BackupResult { data class Success(val eventCount: Int) : BackupResult diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index fdc17d3..4a2331a 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -197,6 +197,14 @@ %d hour %d hours + + %d day + %d days + + + %d week + %d weeks + (No title) @@ -363,6 +371,26 @@ Backup Local calendars aren\'t synced anywhere, so export them to an .ics file to keep a copy. Export as .ics file + Automatic backup + Periodically export your local calendars to a folder as an .ics file. + Backup folder + Tap to choose a folder + Interval + Every %1$s + Minimum 30 minutes. + No automatic backup yet + Last backup: %1$s + Last backup failed: %1$s + Backup + Warns if automatic backups fail repeatedly. + Automatic backup failed + Calendula couldn\'t write the backup file. Check the backup folder in Settings. + + Minutes + Hours + Days + Weeks + Couldn\'t export the backup. Exported %d event. diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 0aee61f..6b13d4f 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -26,10 +26,14 @@ lifecycleCompose = "2.10.0" androidxTestRules = "1.7.0" # Glance: 1.1.1 is the latest stable (1.2.0 is still rc, 1.3.0 alpha). glance = "1.1.1" +work = "2.10.1" +documentfile = "1.0.1" [libraries] # AndroidX core androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" } +androidx-work-runtime-ktx = { group = "androidx.work", name = "work-runtime-ktx", version.ref = "work" } +androidx-documentfile = { group = "androidx.documentfile", name = "documentfile", version.ref = "documentfile" } androidx-appcompat = { group = "androidx.appcompat", name = "appcompat", version.ref = "appcompat" } androidx-lifecycle-runtime-ktx = { group = "androidx.lifecycle", name = "lifecycle-runtime-ktx", version.ref = "lifecycleRuntime" } androidx-activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activityCompose" } From 1424f0ffc054dfbbde5ba34c78615fe02aa9bc84 Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Sat, 27 Jun 2026 22:17:24 +0200 Subject: [PATCH 22/26] feat(backup): connect one-time export + automatic backup into one card Co-Authored-By: Claude Opus 4.8 (1M context) --- .../calendula/ui/calendars/CalendarsScreen.kt | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/calendars/CalendarsScreen.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/calendars/CalendarsScreen.kt index eeb4086..4900a71 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/calendars/CalendarsScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/calendars/CalendarsScreen.kt @@ -306,21 +306,20 @@ private fun CalendarsList( Spacer(Modifier.height(16.dp)) SectionHeader(stringResource(R.string.calendars_backup_header)) HintText(stringResource(R.string.calendars_backup_hint)) + // One connected card: the one-time export on top, then automatic + // backup (and its folder/interval rows when on). GroupedRow( title = stringResource(R.string.calendars_backup_action), - position = Position.Alone, + position = Position.Top, leading = { LeadingAvatar(Icons.Default.FileDownload) }, onClick = { runCatching { createBackup.launch("calendula-backup-${LocalDate.now()}.ics") } }, ) - - // Automatic periodic export to a chosen folder (issue #8). - Spacer(Modifier.height(16.dp)) GroupedRow( title = stringResource(R.string.calendars_auto_backup), summary = stringResource(R.string.calendars_auto_backup_hint), - position = if (autoBackup.enabled) Position.Top else Position.Alone, + position = if (autoBackup.enabled) Position.Middle else Position.Bottom, leading = { LeadingAvatar(Icons.Default.Schedule) }, trailing = { Switch(checked = autoBackup.enabled, onCheckedChange = onSetAutoBackupEnabled) From dc478516cdfb68596018c4a8f36f1aa2733c824f Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Sat, 27 Jun 2026 22:35:58 +0200 Subject: [PATCH 23/26] fix(backup): stop orphaned backup work when disabled MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Turning automatic backup off only cancelled the periodic work, so a run-now that kept failing (e.g. its folder was deleted) retried forever and spammed failure notifications. - Cancel the immediate "run now" work too when backup is disabled. - Worker no-ops (no retry) when the toggle is off, so already-queued work can't revive itself. - Reconcile scheduled work against saved settings on every app launch — re-arms after a reinstall and clears orphaned work once backup is off. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../jeanlucmakiola/calendula/CalendulaApp.kt | 27 +++++++++++++++++++ .../calendula/data/backup/AutoBackup.kt | 6 +++++ 2 files changed, 33 insertions(+) diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/CalendulaApp.kt b/app/src/main/java/de/jeanlucmakiola/calendula/CalendulaApp.kt index 2ec6c24..ac300d1 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/CalendulaApp.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/CalendulaApp.kt @@ -1,8 +1,16 @@ package de.jeanlucmakiola.calendula import android.app.Application +import dagger.hilt.android.EntryPointAccessors import dagger.hilt.android.HiltAndroidApp +import de.jeanlucmakiola.calendula.data.backup.BackupScheduler +import de.jeanlucmakiola.calendula.data.backup.BackupWorker import de.jeanlucmakiola.calendula.data.crash.CrashReporter +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.launch /** * Application entry point. Registered as android:name=".CalendulaApp" @@ -16,5 +24,24 @@ class CalendulaApp : Application() { // Install first thing so startup crashes are captured too (privacy- // respecting, on-device; the user submits the report by hand). CrashReporter.install(this) + reconcileAutoBackup() + } + + /** + * Bring the scheduled auto-backup work back in line with the saved settings + * on every launch — re-arms it after a reinstall and, crucially, cancels any + * orphaned work once backup has been turned off. + */ + private fun reconcileAutoBackup() { + val deps = EntryPointAccessors.fromApplication(this, BackupWorker.Deps::class.java) + CoroutineScope(SupervisorJob() + Dispatchers.Default).launch { + val prefs = deps.settingsPrefs() + BackupScheduler.apply( + context = this@CalendulaApp, + enabled = prefs.autoBackupEnabled.first(), + intervalMinutes = prefs.autoBackupIntervalMinutes.first(), + hasFolder = prefs.autoBackupFolderUri.first() != null, + ) + } } } diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/data/backup/AutoBackup.kt b/app/src/main/java/de/jeanlucmakiola/calendula/data/backup/AutoBackup.kt index c08fa72..64f5091 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/data/backup/AutoBackup.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/data/backup/AutoBackup.kt @@ -55,6 +55,9 @@ object BackupScheduler { val workManager = WorkManager.getInstance(context) if (!enabled || !hasFolder) { workManager.cancelUniqueWork(WORK_NAME) + // Also cancel any pending immediate run — otherwise a failing run-now + // keeps retrying after the user has turned backup off. + workManager.cancelUniqueWork(WORK_NAME_NOW) return } // WorkManager's own floor is 15 min; the UI floors the user choice at 30. @@ -106,6 +109,9 @@ class BackupWorker( override suspend fun doWork(): Result { val deps = EntryPointAccessors.fromApplication(applicationContext, Deps::class.java) val prefs = deps.settingsPrefs() + // Respect the toggle even for already-queued work: if backup was turned + // off, no-op (and don't retry) so a lingering run can't revive itself. + if (!prefs.autoBackupEnabled.first()) return Result.success() val folder = prefs.autoBackupFolderUri.first() ?: return Result.failure() // nothing to write to — leave scheduling to settings val now = System.currentTimeMillis() From e8b87446f73552d717d077de46224fa98600380b Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Sat, 27 Jun 2026 22:57:44 +0200 Subject: [PATCH 24/26] docs(changelog): document settings redesign + auto-backup for 2.11.0 Add the Calendars manager redesign, Notifications reorg, QS-tile move, event-form field icons, and automatic local-calendar backup (#8) to the 2.11.0 changelog; sync the F-Droid per-version changelog. Co-Authored-By: Claude Opus 4.8 (1M context) --- CHANGELOG.md | 20 +++++++++++++++++++ .../android/en-US/changelogs/21100.txt | 18 +++++++++++++++++ 2 files changed, 38 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 174eef8..c12bce5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -30,10 +30,28 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 top naming the exact dates in view, with a button to switch the range just for the current session (it resets when you reopen the app); the bar can be turned off. Thanks to @zmaherdev for the suggestion ([#4]). +- Automatic backup of local calendars. A new **Automatic backup** option + (Settings → Calendars → Backup) periodically exports your local calendars to an + `.ics` file in a folder you choose, on an interval you set (from 30 minutes up). + Useful for file-based syncing such as Syncthing, or simply as a safety net — it + is a one-way export and never touches your synced accounts. Thanks to @shield + for the discussion that inspired it ([#7], [#8]). +- Field icons in the event-form settings. Each optional-field toggle (Settings → + Event form) now shows the same icon the field uses in the new-event form, so + the list is easier to scan. ### Changed - Tidied **Settings → Appearance** into clearer groups (theme & colour, calendar, agenda), and the agenda's empty state now reads "You're all caught up". +- Reworked **Settings → Calendars**. Local and synced calendars are now grouped + into collapsible, source-branded cards — each account shows its app's icon — + with a per-account menu to enable or disable all of its calendars at once or + open the account in its source app. +- Reorganised **Settings → Notifications**: reliable-delivery and snooze settings + moved up with the other global reminder options, and the per-calendar reminder + overrides now fold into a single expandable section. +- Moved **Add Quick Settings tile** out of the event-form section to its own + top-level entry in Settings. ## [2.10.0] — 2026-06-25 @@ -661,3 +679,5 @@ automatically, with zero telemetry and no internet permission. [#4]: https://codeberg.org/jlmakiola/calendula/issues/4 [#5]: https://codeberg.org/jlmakiola/calendula/issues/5 [#6]: https://codeberg.org/jlmakiola/calendula/issues/6 +[#7]: https://codeberg.org/jlmakiola/calendula/issues/7 +[#8]: https://codeberg.org/jlmakiola/calendula/issues/8 diff --git a/fastlane/metadata/android/en-US/changelogs/21100.txt b/fastlane/metadata/android/en-US/changelogs/21100.txt index 52ecd4b..c88ec41 100644 --- a/fastlane/metadata/android/en-US/changelogs/21100.txt +++ b/fastlane/metadata/android/en-US/changelogs/21100.txt @@ -21,8 +21,26 @@ top naming the exact dates in view, with a button to switch the range just for the current session (it resets when you reopen the app); the bar can be turned off. Thanks to @zmaherdev for the suggestion ([#4]). +- Automatic backup of local calendars. A new **Automatic backup** option + (Settings → Calendars → Backup) periodically exports your local calendars to an + `.ics` file in a folder you choose, on an interval you set (from 30 minutes up). + Useful for file-based syncing such as Syncthing, or simply as a safety net — it + is a one-way export and never touches your synced accounts. Thanks to @shield + for the discussion that inspired it ([#7], [#8]). +- Field icons in the event-form settings. Each optional-field toggle (Settings → + Event form) now shows the same icon the field uses in the new-event form, so + the list is easier to scan. ### Changed - Tidied **Settings → Appearance** into clearer groups (theme & colour, calendar, agenda), and the agenda's empty state now reads "You're all caught up". +- Reworked **Settings → Calendars**. Local and synced calendars are now grouped + into collapsible, source-branded cards — each account shows its app's icon — + with a per-account menu to enable or disable all of its calendars at once or + open the account in its source app. +- Reorganised **Settings → Notifications**: reliable-delivery and snooze settings + moved up with the other global reminder options, and the per-calendar reminder + overrides now fold into a single expandable section. +- Moved **Add Quick Settings tile** out of the event-form section to its own + top-level entry in Settings. From 1664625bc596daa78896bd7a80305b44a1682409 Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Sat, 27 Jun 2026 23:10:06 +0200 Subject: [PATCH 25/26] feat(translations): add "Help translate" link to the Weblate engage page Invite community translations: a new Settings > Help translate row (next to App language) opens the project's Weblate engage page, plus a Translations section in the README. Documented in the 2.11.0 changelog. Co-Authored-By: Claude Opus 4.8 (1M context) --- CHANGELOG.md | 3 +++ README.md | 14 +++++++++++++- .../calendula/ui/settings/SettingsScreen.kt | 16 ++++++++++++++++ app/src/main/res/values/strings.xml | 3 +++ .../metadata/android/en-US/changelogs/21100.txt | 3 +++ 5 files changed, 38 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c12bce5..7f79839 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -39,6 +39,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Field icons in the event-form settings. Each optional-field toggle (Settings → Event form) now shows the same icon the field uses in the new-event form, so the list is easier to scan. +- Help translate Calendula. A new **Settings → Help translate** link opens the + project's Weblate, where you can add or improve a language in your browser — no + coding needed. Contributions in any language are welcome. ### Changed - Tidied **Settings → Appearance** into clearer groups (theme & colour, calendar, diff --git a/README.md b/README.md index 994ecd2..e6d4b2e 100644 --- a/README.md +++ b/README.md @@ -68,7 +68,8 @@ database, no sync stack reinvented. - Real Material 3 Expressive throughout — dynamic color (Android 12+), expressive motion and shapes, light/dark theme -- German and English UI, per-app language setting +- German and English UI, per-app language setting — and [open to community + translations](#-translations) - **Zero telemetry, zero analytics, no internet permission** — your data never leaves the device @@ -108,6 +109,17 @@ without reinstalling. Or build from source — see below. - **[Architecture](docs/ARCHITECTURE.md)** — the layered design and key pipelines - **[Roadmap](.planning/ROADMAP.md)** — what's shipped and what's next +## 🌍 Translations + +Calendula ships in German and English, and you're warmly invited to add your +language. Translations are managed on a self-hosted **Weblate**: + +**→ [Help translate Calendula](https://weblate.dev.jeanlucmakiola.de/engage/calendula/)** + +No coding needed — register on the Weblate server, pick (or request) a language, +and translate the strings in your browser. You can also reach this link in the +app under **Settings → Help translate**. + ## 📜 License [MIT](LICENSE) — Jean-Luc Makiola, 2026 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 dab5958..c5e5c22 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 @@ -48,6 +48,7 @@ import androidx.compose.material.icons.filled.Gavel import androidx.compose.material.icons.filled.Language import androidx.compose.material.icons.filled.Notifications import androidx.compose.material.icons.filled.Palette +import androidx.compose.material.icons.filled.Translate import androidx.compose.material.icons.filled.Tune import androidx.compose.material3.FilledTonalButton import androidx.compose.material3.Icon @@ -223,6 +224,7 @@ private fun SettingsHub( onClick = onManageCalendars, ) LanguageRow(position = Position.Middle) + HelpTranslateRow(position = Position.Middle) // One-tap add of the "New event" Quick Settings tile. The system prompt // is API 33+; on older versions the tile is still addable manually from // the QS editor, so the row simply doesn't appear here. @@ -288,6 +290,20 @@ private fun QuickSettingsTileRow(position: Position) { ) } +/** Opens the project's Weblate engage page so users can contribute translations. */ +@Composable +private fun HelpTranslateRow(position: Position) { + val context = LocalContext.current + val url = stringResource(R.string.about_translate_url) + GroupedRow( + title = stringResource(R.string.settings_translate), + summary = stringResource(R.string.settings_translate_hint), + position = position, + leading = { CategoryIcon(Icons.Default.Translate, ChipAccent.Neutral) }, + onClick = { openUrl(context, url) }, + ) +} + @Composable private fun LanguageRow(position: Position) { val context = LocalContext.current diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 4a2331a..bf0b417 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -330,6 +330,8 @@ Language App language System default + Help translate + Add or improve a language on Weblate Theme, default view, week start Default fields for new events @@ -436,6 +438,7 @@ https://gitea.jeanlucmakiola.de/makiolaj/calendula https://gitea.jeanlucmakiola.de/makiolaj/calendula/src/branch/main/LICENSE https://ko-fi.com/jeanlucmakiola + https://weblate.dev.jeanlucmakiola.de/engage/calendula/ diff --git a/fastlane/metadata/android/en-US/changelogs/21100.txt b/fastlane/metadata/android/en-US/changelogs/21100.txt index c88ec41..a7dc625 100644 --- a/fastlane/metadata/android/en-US/changelogs/21100.txt +++ b/fastlane/metadata/android/en-US/changelogs/21100.txt @@ -30,6 +30,9 @@ - Field icons in the event-form settings. Each optional-field toggle (Settings → Event form) now shows the same icon the field uses in the new-event form, so the list is easier to scan. +- Help translate Calendula. A new **Settings → Help translate** link opens the + project's Weblate, where you can add or improve a language in your browser — no + coding needed. Contributions in any language are welcome. ### Changed - Tidied **Settings → Appearance** into clearer groups (theme & colour, calendar, From b1151b65167c92caf0e3e8b121a324c2c7d55b85 Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Sat, 27 Jun 2026 23:18:06 +0200 Subject: [PATCH 26/26] refactor(translations): move Help translate into the App language page Per review, it shouldn't be a top-level Settings entry: the "Help translate" link now sits at the top of the full-screen App language picker (OptionPicker gains an optional header slot). Updated README + changelog wording to match. Co-Authored-By: Claude Opus 4.8 (1M context) --- CHANGELOG.md | 7 ++--- README.md | 2 +- .../calendula/ui/common/Picker.kt | 2 ++ .../calendula/ui/settings/SettingsScreen.kt | 27 +++++++++---------- .../android/en-US/changelogs/21100.txt | 7 ++--- 5 files changed, 23 insertions(+), 22 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7f79839..db69497 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -39,9 +39,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Field icons in the event-form settings. Each optional-field toggle (Settings → Event form) now shows the same icon the field uses in the new-event form, so the list is easier to scan. -- Help translate Calendula. A new **Settings → Help translate** link opens the - project's Weblate, where you can add or improve a language in your browser — no - coding needed. Contributions in any language are welcome. +- Help translate Calendula. A **Help translate** link at the top of **Settings → + App language** opens the project's Weblate, where you can add or improve a + language in your browser — no coding needed. Contributions in any language are + welcome. ### Changed - Tidied **Settings → Appearance** into clearer groups (theme & colour, calendar, diff --git a/README.md b/README.md index e6d4b2e..f909377 100644 --- a/README.md +++ b/README.md @@ -118,7 +118,7 @@ language. Translations are managed on a self-hosted **Weblate**: No coding needed — register on the Weblate server, pick (or request) a language, and translate the strings in your browser. You can also reach this link in the -app under **Settings → Help translate**. +app from the top of **Settings → App language**. ## 📜 License 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 05e7ba8..ce6abae 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 @@ -86,8 +86,10 @@ fun OptionPicker( label: @Composable (T) -> String, onSelect: (T) -> Unit, onDismiss: () -> Unit, + header: (@Composable ColumnScope.() -> Unit)? = null, ) { FullScreenPicker(title = title, onDismiss = onDismiss) { + header?.invoke(this) options.forEachIndexed { index, option -> val isSelected = option == selected GroupedRow( 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 c5e5c22..fc1447e 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 @@ -224,7 +224,6 @@ private fun SettingsHub( onClick = onManageCalendars, ) LanguageRow(position = Position.Middle) - HelpTranslateRow(position = Position.Middle) // One-tap add of the "New event" Quick Settings tile. The system prompt // is API 33+; on older versions the tile is still addable manually from // the QS editor, so the row simply doesn't appear here. @@ -290,20 +289,6 @@ private fun QuickSettingsTileRow(position: Position) { ) } -/** Opens the project's Weblate engage page so users can contribute translations. */ -@Composable -private fun HelpTranslateRow(position: Position) { - val context = LocalContext.current - val url = stringResource(R.string.about_translate_url) - GroupedRow( - title = stringResource(R.string.settings_translate), - summary = stringResource(R.string.settings_translate_hint), - position = position, - leading = { CategoryIcon(Icons.Default.Translate, ChipAccent.Neutral) }, - onClick = { openUrl(context, url) }, - ) -} - @Composable private fun LanguageRow(position: Position) { val context = LocalContext.current @@ -334,6 +319,18 @@ private fun LanguageRow(position: Position) { AppLanguage.apply(it) }, onDismiss = { showDialog = false }, + // Invite contributions right where users pick their language. + header = { + val translateUrl = stringResource(R.string.about_translate_url) + GroupedRow( + title = stringResource(R.string.settings_translate), + summary = stringResource(R.string.settings_translate_hint), + position = Position.Alone, + leading = { CategoryIcon(Icons.Default.Translate, ChipAccent.Neutral) }, + onClick = { openUrl(context, translateUrl) }, + ) + Spacer(Modifier.height(16.dp)) + }, ) } } diff --git a/fastlane/metadata/android/en-US/changelogs/21100.txt b/fastlane/metadata/android/en-US/changelogs/21100.txt index a7dc625..a814fd3 100644 --- a/fastlane/metadata/android/en-US/changelogs/21100.txt +++ b/fastlane/metadata/android/en-US/changelogs/21100.txt @@ -30,9 +30,10 @@ - Field icons in the event-form settings. Each optional-field toggle (Settings → Event form) now shows the same icon the field uses in the new-event form, so the list is easier to scan. -- Help translate Calendula. A new **Settings → Help translate** link opens the - project's Weblate, where you can add or improve a language in your browser — no - coding needed. Contributions in any language are welcome. +- Help translate Calendula. A **Help translate** link at the top of **Settings → + App language** opens the project's Weblate, where you can add or improve a + language in your browser — no coding needed. Contributions in any language are + welcome. ### Changed - Tidied **Settings → Appearance** into clearer groups (theme & colour, calendar,