From 47e8036000a59c61d5b0e75fa3ea344045e45469 Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Sat, 27 Jun 2026 13:45:01 +0200 Subject: [PATCH 1/4] 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 2/4] 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 3/4] 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 9994e8c534c202219b57abdb0be41173edc6a017 Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Sat, 27 Jun 2026 14:34:48 +0200 Subject: [PATCH 4/4] 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]). +