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 8585832..45c6c81 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 @@ -28,6 +28,22 @@ sealed interface WeekStartPref { data class Day(val day: DayOfWeek) : WeekStartPref } +/** + * 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. [WeekStartPref.Auto] * reads the locale's convention (e.g. Monday in DE, Sunday in en-US). @@ -75,6 +91,15 @@ class SettingsPrefs @Inject constructor( store.edit { it[WEEK_START_KEY] = pref.storageValue() } } + /** 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 @@ -264,6 +289,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 062b7b0..62cdfa4 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 @@ -427,6 +428,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( @@ -469,9 +471,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) { @@ -494,6 +502,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), @@ -1009,6 +1027,15 @@ private fun weekStartLabel(pref: WeekStartPref): String = when (pref) { .getDisplayName(JavaTextStyle.FULL, currentLocale()) } +@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 b625722..c4b9baf 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 b133ec8..59f3460 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -276,6 +276,10 @@ Erfordert Android 12 oder neuer Wochenstart Automatisch + 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 8851159..b83d6f8 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -269,6 +269,10 @@ Requires Android 12 or newer Week starts on Automatic + 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") + } +}