diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/data/prefs/SettingsPrefs.kt b/app/src/main/java/de/jeanlucmakiola/calendula/data/prefs/SettingsPrefs.kt index cb4e610..2b0d8ea 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/data/prefs/SettingsPrefs.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/data/prefs/SettingsPrefs.kt @@ -22,6 +22,7 @@ import de.jeanlucmakiola.calendula.ui.common.CalendarView import de.jeanlucmakiola.calendula.ui.common.IMPLEMENTED_VIEWS import de.jeanlucmakiola.calendula.ui.common.QuickSwitchConfig import de.jeanlucmakiola.calendula.ui.theme.FONT_SYSTEM_TOKEN +import java.time.ZoneId import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.map import kotlinx.datetime.DayOfWeek @@ -354,6 +355,26 @@ class SettingsPrefs @Inject constructor( } } + /** + * Zones the user picked recently, most recent first — the timezone picker's + * default list, since scrolling ~600 zones to re-find the two you actually + * use is the whole problem. Stored comma-joined (IANA ids never contain a + * comma); unparseable ids are dropped on read, so a zone the tz database + * later retires can't wedge the list. + */ + val recentTimeZones: Flow> = store.data.map { prefs -> + parseRecentTimeZones(prefs[RECENT_TIMEZONES_KEY]) + } + + suspend fun addRecentTimeZone(zoneId: String) { + store.edit { prefs -> + val updated = (listOf(zoneId) + parseRecentTimeZones(prefs[RECENT_TIMEZONES_KEY])) + .distinct() + .take(MAX_RECENT_TIME_ZONES) + prefs[RECENT_TIMEZONES_KEY] = updated.joinToString(",") + } + } + /** * Whether opening the new-event form focuses the title field and raises the * keyboard straight away (issue #10). Default ON — a new event almost always @@ -650,6 +671,13 @@ class SettingsPrefs @Inject constructor( private fun titleTemplateKey(type: SpecialDateType) = stringPreferencesKey("special_dates_title_${type.name}") + private fun parseRecentTimeZones(stored: String?): List = + stored?.split(',').orEmpty() + .map { it.trim() } + .filter { it.isNotEmpty() && runCatching { ZoneId.of(it) }.isSuccess } + .distinct() + .take(MAX_RECENT_TIME_ZONES) + private fun parseFormFields(stored: String?): Set = when (stored) { null -> DEFAULT_FORM_FIELDS else -> stored.split(',') @@ -736,6 +764,10 @@ class SettingsPrefs @Inject constructor( stringPreferencesKey("per_calendar_allday_reminder_override") internal val DEFAULT_FORM_FIELDS = setOf(EventFormField.Location, EventFormField.Description) + internal val RECENT_TIMEZONES_KEY = stringPreferencesKey("recent_time_zones") + + /** Enough to cover the zones a user actually recurs to, without a wall of rows. */ + internal const val MAX_RECENT_TIME_ZONES = 5 internal val AUTO_BACKUP_ENABLED_KEY = booleanPreferencesKey("auto_backup_enabled") internal val AUTO_BACKUP_INTERVAL_KEY = longPreferencesKey("auto_backup_interval_minutes") internal val AUTO_BACKUP_FOLDER_KEY = stringPreferencesKey("auto_backup_folder_uri") diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/EventFormFieldVisuals.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/EventFormFieldVisuals.kt index aa07543..91d84a5 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/EventFormFieldVisuals.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/EventFormFieldVisuals.kt @@ -9,6 +9,7 @@ import androidx.compose.material.icons.filled.Notifications import androidx.compose.material.icons.filled.Palette import androidx.compose.material.icons.filled.People import androidx.compose.material.icons.filled.Place +import androidx.compose.material.icons.filled.Public import androidx.compose.material.icons.filled.Repeat import androidx.compose.ui.graphics.vector.ImageVector import de.jeanlucmakiola.calendula.R @@ -24,6 +25,7 @@ import de.jeanlucmakiola.calendula.domain.EventFormField fun eventFormFieldLabel(field: EventFormField): Int = when (field) { EventFormField.Location -> R.string.event_detail_location EventFormField.Description -> R.string.event_detail_description + EventFormField.Timezone -> R.string.event_detail_timezone EventFormField.Reminders -> R.string.event_detail_reminders EventFormField.Recurrence -> R.string.event_detail_recurrence EventFormField.Availability -> R.string.event_edit_availability @@ -35,6 +37,7 @@ fun eventFormFieldLabel(field: EventFormField): Int = when (field) { fun eventFormFieldIcon(field: EventFormField): ImageVector = when (field) { EventFormField.Location -> Icons.Default.Place EventFormField.Description -> Icons.AutoMirrored.Filled.Notes + EventFormField.Timezone -> Icons.Default.Public EventFormField.Reminders -> Icons.Default.Notifications EventFormField.Recurrence -> Icons.Default.Repeat EventFormField.Availability -> Icons.Default.EventAvailable diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/TimeZonePicker.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/TimeZonePicker.kt new file mode 100644 index 0000000..4b33d87 --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/TimeZonePicker.kt @@ -0,0 +1,203 @@ +package de.jeanlucmakiola.calendula.ui.common + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Check +import androidx.compose.material.icons.filled.Public +import androidx.compose.material.icons.filled.Search +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import de.jeanlucmakiola.calendula.R +import de.jeanlucmakiola.calendula.domain.TimeZoneOption +import de.jeanlucmakiola.calendula.domain.filterTimeZones +import de.jeanlucmakiola.calendula.domain.formatGmtOffset +import de.jeanlucmakiola.calendula.domain.timeZoneOptions +import de.jeanlucmakiola.floret.components.FullScreenPicker +import de.jeanlucmakiola.floret.components.GroupedRow +import de.jeanlucmakiola.floret.components.Position +import de.jeanlucmakiola.floret.components.positionOf +import de.jeanlucmakiola.floret.locale.currentLocale + +/** + * Full-screen zone picker: a query field over every zone the JVM knows, with the + * device zone and recently-picked ones pinned on top so the common case needs no + * typing. [selected] is the pinned zone id, or null when the event follows the + * device — picking the device row reports null back, which is what keeps an + * unpinned event unpinned rather than freezing it to today's zone. + * + * Unlike the kit's [de.jeanlucmakiola.floret.components.OptionPicker], this + * drives its own [LazyColumn] (hence `scrollable = false`): ~600 options is far + * past what an eagerly composed column can carry. + */ +@Composable +fun TimeZonePickerDialog( + selected: String?, + deviceZoneId: String, + recents: List, + onSelect: (String?) -> Unit, + onDismiss: () -> Unit, +) { + var query by rememberSaveable { mutableStateOf("") } + // ~600 zones, each resolving a localized name and a DST-aware offset: build + // once per open, then filter the result per keystroke. + val locale = currentLocale() + val allZones = remember(locale) { timeZoneOptions(locale) } + val matches = remember(allZones, query) { filterTimeZones(allZones, query) } + val recentZones = remember(allZones, recents) { + recents.mapNotNull { id -> allZones.firstOrNull { it.id == id } } + } + val searching = query.isNotBlank() + + fun choose(zoneId: String?) { + onSelect(zoneId) + onDismiss() + } + + FullScreenPicker( + title = stringResource(R.string.event_detail_timezone), + onDismiss = onDismiss, + scrollable = false, + ) { + OutlinedTextField( + value = query, + onValueChange = { query = it }, + singleLine = true, + leadingIcon = { Icon(Icons.Default.Search, contentDescription = null) }, + placeholder = { Text(stringResource(R.string.event_edit_timezone_search)) }, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 8.dp), + ) + LazyColumn(contentPadding = PaddingValues(top = 8.dp, bottom = 24.dp)) { + if (!searching) { + // The device row is the "unpinned" choice, so it reads as a mode + // rather than as a zone among zones — it stays out of the big + // list and never carries an offset. + item(key = "device") { + GroupedRow( + title = stringResource(R.string.event_edit_timezone_device), + summary = zoneSummary(deviceZoneId, allZones), + position = Position.Alone, + selected = selected == null, + leading = { Icon(Icons.Default.Public, contentDescription = null) }, + trailing = if (selected == null) { + { SelectedCheck() } + } else { + null + }, + onClick = { choose(null) }, + ) + } + if (recentZones.isNotEmpty()) { + item(key = "recent_header") { + SectionHeader(stringResource(R.string.event_edit_timezone_recent)) + } + itemsIndexed( + items = recentZones, + key = { _, zone -> "recent_${zone.id}" }, + ) { index, zone -> + ZoneRow( + zone = zone, + position = positionOf(index, recentZones.size), + selected = zone.id == selected, + onClick = { choose(zone.id) }, + ) + } + } + item(key = "all_header") { + SectionHeader(stringResource(R.string.event_edit_timezone_all)) + } + } + + if (searching && matches.isEmpty()) { + item(key = "empty") { + Text( + text = stringResource(R.string.event_edit_timezone_none, query), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 32.dp, vertical = 48.dp), + ) + } + } + + itemsIndexed( + items = matches, + key = { _, zone -> "zone_${zone.id}" }, + ) { index, zone -> + ZoneRow( + zone = zone, + position = positionOf(index, matches.size), + selected = zone.id == selected, + onClick = { choose(zone.id) }, + ) + } + } + } +} + +/** A small primary-coloured group label, matching the settings screens. */ +@Composable +private fun SectionHeader(text: String) { + Text( + text = text, + style = MaterialTheme.typography.labelLarge, + color = MaterialTheme.colorScheme.primary, + modifier = Modifier.padding(start = 24.dp, end = 24.dp, top = 16.dp, bottom = 4.dp), + ) +} + +@Composable +private fun SelectedCheck() { + Icon( + imageVector = Icons.Default.Check, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + ) +} + +@Composable +private fun ZoneRow( + zone: TimeZoneOption, + position: Position, + selected: Boolean, + onClick: () -> Unit, +) { + GroupedRow( + title = zone.city, + summary = "${zone.displayName} · ${formatGmtOffset(zone.offsetMinutes)}", + position = position, + selected = selected, + trailing = if (selected) { + { SelectedCheck() } + } else { + null + }, + onClick = onClick, + ) +} + +/** "Central European Time · GMT+02:00" for [zoneId], or the bare id if unknown. */ +private fun zoneSummary(zoneId: String, allZones: List): String = + allZones.firstOrNull { it.id == zoneId } + ?.let { "${it.displayName} · ${formatGmtOffset(it.offsetMinutes)}" } + ?: zoneId diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/edit/EventEditScreen.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/edit/EventEditScreen.kt index 11ec532..67e47a0 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/edit/EventEditScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/edit/EventEditScreen.kt @@ -47,6 +47,7 @@ import androidx.compose.material.icons.filled.Person import androidx.compose.material.icons.filled.PersonAdd import androidx.compose.material.icons.filled.Place import androidx.compose.material.icons.filled.Public +import androidx.compose.material.icons.filled.Public import androidx.compose.material.icons.filled.Repeat import androidx.compose.material.icons.filled.Schedule import androidx.compose.material.icons.filled.Tune @@ -111,6 +112,8 @@ import de.jeanlucmakiola.calendula.domain.EventAttendee import de.jeanlucmakiola.calendula.domain.EventColorOption import de.jeanlucmakiola.calendula.domain.EventForm import de.jeanlucmakiola.calendula.domain.EventFormField +import de.jeanlucmakiola.calendula.domain.formatGmtOffset +import de.jeanlucmakiola.calendula.domain.timeZoneOptionOf import de.jeanlucmakiola.calendula.domain.EventFormProblem import de.jeanlucmakiola.calendula.domain.RecurrenceEnd import de.jeanlucmakiola.calendula.domain.RecurrenceFreq @@ -126,6 +129,7 @@ import de.jeanlucmakiola.calendula.data.calendar.CalendarColorPalette import de.jeanlucmakiola.calendula.ui.common.CalendarPickerGroups import de.jeanlucmakiola.calendula.ui.common.CalendarDatePickerDialog import de.jeanlucmakiola.calendula.ui.common.ColorSwatchRow +import de.jeanlucmakiola.calendula.ui.common.TimeZonePickerDialog import de.jeanlucmakiola.calendula.ui.common.LocalSoftenColors import de.jeanlucmakiola.calendula.ui.common.eventFill import de.jeanlucmakiola.floret.components.DialogAmountField @@ -160,6 +164,7 @@ import kotlinx.datetime.toJavaLocalDate import kotlinx.datetime.toKotlinDayOfWeek import kotlinx.datetime.toJavaLocalTime import kotlinx.datetime.toLocalDateTime +import java.time.ZoneId import java.time.format.DateTimeFormatter import java.time.format.FormatStyle import java.time.format.TextStyle as JavaTextStyle @@ -506,6 +511,7 @@ private fun EventEditContent( var showRecurrencePicker by rememberSaveable { mutableStateOf(false) } var showVisibilityPicker by rememberSaveable { mutableStateOf(false) } var showColorPicker by rememberSaveable { mutableStateOf(false) } + var showTimezonePicker by rememberSaveable { mutableStateOf(false) } var showFieldPicker by rememberSaveable { mutableStateOf(false) } // Pick a guest from contacts: a one-shot system picker returning the chosen @@ -706,6 +712,43 @@ private fun EventEditContent( // Optional sections: which ones render by default is a setting; the // rest unfold behind the "more fields" button below. + OptionalFormSection(visible = EventFormField.Timezone in state.visibleFields) { + Spacer(Modifier.height(gap)) + EditCard( + icon = Icons.Default.Public, + iconContentDescription = null, + onClick = { showTimezonePicker = true }, + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.fillMaxWidth(), + ) { + val pinned = remember(form.timezone, locale) { + form.timezone?.let { timeZoneOptionOf(it, locale) } + } + Column(modifier = Modifier.weight(1f)) { + Text( + text = pinned?.city + ?: stringResource(R.string.event_edit_timezone_device), + style = MaterialTheme.typography.titleMedium, + ) + Text( + text = pinned + ?.let { "${it.displayName} · ${formatGmtOffset(it.offsetMinutes)}" } + ?: stringResource(R.string.event_edit_timezone_device_summary), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + Icon( + imageVector = Icons.Default.ArrowDropDown, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } + OptionalFormSection(visible = EventFormField.Location in state.visibleFields) { Spacer(Modifier.height(gap)) EditCard( @@ -1081,6 +1124,16 @@ private fun EventEditContent( ) } + if (showTimezonePicker) { + TimeZonePickerDialog( + selected = form.timezone, + deviceZoneId = ZoneId.systemDefault().id, + recents = state.recentTimeZones, + onSelect = viewModel::setTimezone, + onDismiss = { showTimezonePicker = false }, + ) + } + if (showColorPicker) { ColorPickerDialog( palette = state.colorPalette, diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/edit/EventEditUiState.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/edit/EventEditUiState.kt index 934b792..6a68d04 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/edit/EventEditUiState.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/edit/EventEditUiState.kt @@ -27,6 +27,8 @@ data class EventEditUiState( * neither list. */ val hiddenFields: List = emptyList(), + /** Recently picked zones, most recent first — the zone picker's shortlist. */ + val recentTimeZones: List = emptyList(), /** True while editing an existing event (the calendar is then fixed). */ val isEditing: Boolean = false, /** diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/edit/EventEditViewModel.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/edit/EventEditViewModel.kt index 8308538..b339f39 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/edit/EventEditViewModel.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/edit/EventEditViewModel.kt @@ -39,6 +39,7 @@ import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext import kotlinx.datetime.LocalDate import kotlinx.datetime.LocalDateTime import kotlinx.datetime.LocalTime @@ -219,7 +220,8 @@ class EventEditViewModel @Inject constructor( ).flowOn(io), colorPalette, allCalendars, - ) { local, external, palette, allCalendars -> + settingsPrefs.recentTimeZones.flowOn(io), + ) { local, external, palette, allCalendars, recentTimeZones -> val form = local.form ?: return@combine null val resolvedId = form.calendarId ?: external.lastUsed?.takeIf { id -> external.writable.any { it.id == id } } @@ -235,14 +237,19 @@ class EventEditViewModel @Inject constructor( val pickerCalendars = if (isManaged && resolvedCalendar != null) external.writable + resolvedCalendar else external.writable - val visibleFields = external.defaultFields + local.revealed + // An all-day event is date-anchored, so a zone is meaningless on it — + // the field is withheld from both lists rather than shown as a no-op. + val offerableFields = EventFormField.entries.toSet() - + if (resolved.isAllDay) setOf(EventFormField.Timezone) else emptySet() + val visibleFields = (external.defaultFields + local.revealed) intersect offerableFields EventEditUiState( form = resolved, calendars = pickerCalendars, problems = if (local.showProblems) resolved.problems() else emptySet(), saveState = local.saveState, visibleFields = visibleFields, - hiddenFields = (EventFormField.entries.toSet() - visibleFields).sorted(), + hiddenFields = (offerableFields - visibleFields).sorted(), + recentTimeZones = recentTimeZones, isEditing = local.editTarget != null, isManaged = isManaged, autofocusTitle = external.autofocusTitle, @@ -453,12 +460,28 @@ class EventEditViewModel @Inject constructor( fun setLocation(value: String) = update { it.copy(location = value) } fun setDescription(value: String) = update { it.copy(description = value) } fun setAllDay(value: Boolean) { - update { it.copy(isAllDay = value) } + // Going all-day drops any pinned zone: the times become bare dates that + // the provider stores UTC-anchored, so a zone could only misrepresent + // them. Coming back out of all-day leaves the event on the device zone, + // which is the same thing a fresh event gets. + update { it.copy(isAllDay = value, timezone = if (value) null else it.timezone) } // The default reminder differs for all-day vs timed; re-apply the // type-appropriate default unless the user has hand-edited it (guarded). applyDefaultReminder() } + /** + * Pin the event to [zoneId], or pass null to follow the device. The zone + * rides along as a recent so the picker can offer it without a search next + * time — only real picks are remembered, not the device fallback. + */ + fun setTimezone(zoneId: String?) { + update { it.copy(timezone = zoneId) } + if (zoneId != null) { + viewModelScope.launch { withContext(io) { settingsPrefs.addRecentTimeZone(zoneId) } } + } + } + /** * Switching calendars drops any chosen colour: a palette key is * account-scoped, and a raw colour may be invalid on the new calendar. diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 06f995d..eb35d31 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -99,6 +99,15 @@ Availability Visibility + + Device time zone + Follows wherever you are + Search time zones + Recent + All time zones + No time zone matches “%1$s” + Use device zone + Color Calendar color diff --git a/floret-kit b/floret-kit index a50878a..3aa4cea 160000 --- a/floret-kit +++ b/floret-kit @@ -1 +1 @@ -Subproject commit a50878a7ac638e725984e9cf447d693c711f52c4 +Subproject commit 3aa4ceada5af0c103d8f03263612524b466f9d19