feat(edit): add a searchable time-zone picker (#31)

Surface the zone as an optional form field, right after the time fields
it qualifies: hidden on ordinary events, revealed automatically when the
event already carries a foreign zone, and withheld entirely while all-day
is on (a date-anchored event has no zone to show).

The picker is a full-screen one per the app's convention, but it can't
reuse OptionPicker: that composes every option eagerly, and ~600 zones
would all compose on open. It drives its own LazyColumn instead, which
needs the kit's new `scrollable = false` — the scaffold's own
verticalScroll would otherwise throw on a nested same-axis scrollable.
The device zone and recently-picked zones pin to the top so the common
case needs no typing; search is accent- and case-insensitive.

Recents persist in DataStore, capped at five, dropping ids the tz
database no longer knows.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-17 15:32:04 +02:00
parent 7634df3cff
commit fefe1f3652
8 changed files with 330 additions and 5 deletions

View File

@@ -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.IMPLEMENTED_VIEWS
import de.jeanlucmakiola.calendula.ui.common.QuickSwitchConfig import de.jeanlucmakiola.calendula.ui.common.QuickSwitchConfig
import de.jeanlucmakiola.calendula.ui.theme.FONT_SYSTEM_TOKEN import de.jeanlucmakiola.calendula.ui.theme.FONT_SYSTEM_TOKEN
import java.time.ZoneId
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
import kotlinx.datetime.DayOfWeek 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<List<String>> = 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 * 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 * 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) = private fun titleTemplateKey(type: SpecialDateType) =
stringPreferencesKey("special_dates_title_${type.name}") stringPreferencesKey("special_dates_title_${type.name}")
private fun parseRecentTimeZones(stored: String?): List<String> =
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<EventFormField> = when (stored) { private fun parseFormFields(stored: String?): Set<EventFormField> = when (stored) {
null -> DEFAULT_FORM_FIELDS null -> DEFAULT_FORM_FIELDS
else -> stored.split(',') else -> stored.split(',')
@@ -736,6 +764,10 @@ class SettingsPrefs @Inject constructor(
stringPreferencesKey("per_calendar_allday_reminder_override") stringPreferencesKey("per_calendar_allday_reminder_override")
internal val DEFAULT_FORM_FIELDS = internal val DEFAULT_FORM_FIELDS =
setOf(EventFormField.Location, EventFormField.Description) 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_ENABLED_KEY = booleanPreferencesKey("auto_backup_enabled")
internal val AUTO_BACKUP_INTERVAL_KEY = longPreferencesKey("auto_backup_interval_minutes") internal val AUTO_BACKUP_INTERVAL_KEY = longPreferencesKey("auto_backup_interval_minutes")
internal val AUTO_BACKUP_FOLDER_KEY = stringPreferencesKey("auto_backup_folder_uri") internal val AUTO_BACKUP_FOLDER_KEY = stringPreferencesKey("auto_backup_folder_uri")

View File

@@ -9,6 +9,7 @@ import androidx.compose.material.icons.filled.Notifications
import androidx.compose.material.icons.filled.Palette import androidx.compose.material.icons.filled.Palette
import androidx.compose.material.icons.filled.People import androidx.compose.material.icons.filled.People
import androidx.compose.material.icons.filled.Place import androidx.compose.material.icons.filled.Place
import androidx.compose.material.icons.filled.Public
import androidx.compose.material.icons.filled.Repeat import androidx.compose.material.icons.filled.Repeat
import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.graphics.vector.ImageVector
import de.jeanlucmakiola.calendula.R import de.jeanlucmakiola.calendula.R
@@ -24,6 +25,7 @@ import de.jeanlucmakiola.calendula.domain.EventFormField
fun eventFormFieldLabel(field: EventFormField): Int = when (field) { fun eventFormFieldLabel(field: EventFormField): Int = when (field) {
EventFormField.Location -> R.string.event_detail_location EventFormField.Location -> R.string.event_detail_location
EventFormField.Description -> R.string.event_detail_description EventFormField.Description -> R.string.event_detail_description
EventFormField.Timezone -> R.string.event_detail_timezone
EventFormField.Reminders -> R.string.event_detail_reminders EventFormField.Reminders -> R.string.event_detail_reminders
EventFormField.Recurrence -> R.string.event_detail_recurrence EventFormField.Recurrence -> R.string.event_detail_recurrence
EventFormField.Availability -> R.string.event_edit_availability 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) { fun eventFormFieldIcon(field: EventFormField): ImageVector = when (field) {
EventFormField.Location -> Icons.Default.Place EventFormField.Location -> Icons.Default.Place
EventFormField.Description -> Icons.AutoMirrored.Filled.Notes EventFormField.Description -> Icons.AutoMirrored.Filled.Notes
EventFormField.Timezone -> Icons.Default.Public
EventFormField.Reminders -> Icons.Default.Notifications EventFormField.Reminders -> Icons.Default.Notifications
EventFormField.Recurrence -> Icons.Default.Repeat EventFormField.Recurrence -> Icons.Default.Repeat
EventFormField.Availability -> Icons.Default.EventAvailable EventFormField.Availability -> Icons.Default.EventAvailable

View File

@@ -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<String>,
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<TimeZoneOption>): String =
allZones.firstOrNull { it.id == zoneId }
?.let { "${it.displayName} · ${formatGmtOffset(it.offsetMinutes)}" }
?: zoneId

View File

@@ -47,6 +47,7 @@ import androidx.compose.material.icons.filled.Person
import androidx.compose.material.icons.filled.PersonAdd import androidx.compose.material.icons.filled.PersonAdd
import androidx.compose.material.icons.filled.Place 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.Public
import androidx.compose.material.icons.filled.Repeat import androidx.compose.material.icons.filled.Repeat
import androidx.compose.material.icons.filled.Schedule import androidx.compose.material.icons.filled.Schedule
import androidx.compose.material.icons.filled.Tune 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.EventColorOption
import de.jeanlucmakiola.calendula.domain.EventForm import de.jeanlucmakiola.calendula.domain.EventForm
import de.jeanlucmakiola.calendula.domain.EventFormField 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.EventFormProblem
import de.jeanlucmakiola.calendula.domain.RecurrenceEnd import de.jeanlucmakiola.calendula.domain.RecurrenceEnd
import de.jeanlucmakiola.calendula.domain.RecurrenceFreq 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.CalendarPickerGroups
import de.jeanlucmakiola.calendula.ui.common.CalendarDatePickerDialog import de.jeanlucmakiola.calendula.ui.common.CalendarDatePickerDialog
import de.jeanlucmakiola.calendula.ui.common.ColorSwatchRow 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.LocalSoftenColors
import de.jeanlucmakiola.calendula.ui.common.eventFill import de.jeanlucmakiola.calendula.ui.common.eventFill
import de.jeanlucmakiola.floret.components.DialogAmountField import de.jeanlucmakiola.floret.components.DialogAmountField
@@ -160,6 +164,7 @@ import kotlinx.datetime.toJavaLocalDate
import kotlinx.datetime.toKotlinDayOfWeek import kotlinx.datetime.toKotlinDayOfWeek
import kotlinx.datetime.toJavaLocalTime import kotlinx.datetime.toJavaLocalTime
import kotlinx.datetime.toLocalDateTime import kotlinx.datetime.toLocalDateTime
import java.time.ZoneId
import java.time.format.DateTimeFormatter import java.time.format.DateTimeFormatter
import java.time.format.FormatStyle import java.time.format.FormatStyle
import java.time.format.TextStyle as JavaTextStyle import java.time.format.TextStyle as JavaTextStyle
@@ -506,6 +511,7 @@ private fun EventEditContent(
var showRecurrencePicker by rememberSaveable { mutableStateOf(false) } var showRecurrencePicker by rememberSaveable { mutableStateOf(false) }
var showVisibilityPicker by rememberSaveable { mutableStateOf(false) } var showVisibilityPicker by rememberSaveable { mutableStateOf(false) }
var showColorPicker by rememberSaveable { mutableStateOf(false) } var showColorPicker by rememberSaveable { mutableStateOf(false) }
var showTimezonePicker by rememberSaveable { mutableStateOf(false) }
var showFieldPicker by rememberSaveable { mutableStateOf(false) } var showFieldPicker by rememberSaveable { mutableStateOf(false) }
// Pick a guest from contacts: a one-shot system picker returning the chosen // 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 // Optional sections: which ones render by default is a setting; the
// rest unfold behind the "more fields" button below. // 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) { OptionalFormSection(visible = EventFormField.Location in state.visibleFields) {
Spacer(Modifier.height(gap)) Spacer(Modifier.height(gap))
EditCard( 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) { if (showColorPicker) {
ColorPickerDialog( ColorPickerDialog(
palette = state.colorPalette, palette = state.colorPalette,

View File

@@ -27,6 +27,8 @@ data class EventEditUiState(
* neither list. * neither list.
*/ */
val hiddenFields: List<EventFormField> = emptyList(), val hiddenFields: List<EventFormField> = emptyList(),
/** Recently picked zones, most recent first — the zone picker's shortlist. */
val recentTimeZones: List<String> = emptyList(),
/** True while editing an existing event (the calendar is then fixed). */ /** True while editing an existing event (the calendar is then fixed). */
val isEditing: Boolean = false, val isEditing: Boolean = false,
/** /**

View File

@@ -39,6 +39,7 @@ import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import kotlinx.datetime.LocalDate import kotlinx.datetime.LocalDate
import kotlinx.datetime.LocalDateTime import kotlinx.datetime.LocalDateTime
import kotlinx.datetime.LocalTime import kotlinx.datetime.LocalTime
@@ -219,7 +220,8 @@ class EventEditViewModel @Inject constructor(
).flowOn(io), ).flowOn(io),
colorPalette, colorPalette,
allCalendars, allCalendars,
) { local, external, palette, allCalendars -> settingsPrefs.recentTimeZones.flowOn(io),
) { local, external, palette, allCalendars, recentTimeZones ->
val form = local.form ?: return@combine null val form = local.form ?: return@combine null
val resolvedId = form.calendarId val resolvedId = form.calendarId
?: external.lastUsed?.takeIf { id -> external.writable.any { it.id == id } } ?: external.lastUsed?.takeIf { id -> external.writable.any { it.id == id } }
@@ -235,14 +237,19 @@ class EventEditViewModel @Inject constructor(
val pickerCalendars = val pickerCalendars =
if (isManaged && resolvedCalendar != null) external.writable + resolvedCalendar if (isManaged && resolvedCalendar != null) external.writable + resolvedCalendar
else external.writable 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( EventEditUiState(
form = resolved, form = resolved,
calendars = pickerCalendars, calendars = pickerCalendars,
problems = if (local.showProblems) resolved.problems() else emptySet(), problems = if (local.showProblems) resolved.problems() else emptySet(),
saveState = local.saveState, saveState = local.saveState,
visibleFields = visibleFields, visibleFields = visibleFields,
hiddenFields = (EventFormField.entries.toSet() - visibleFields).sorted(), hiddenFields = (offerableFields - visibleFields).sorted(),
recentTimeZones = recentTimeZones,
isEditing = local.editTarget != null, isEditing = local.editTarget != null,
isManaged = isManaged, isManaged = isManaged,
autofocusTitle = external.autofocusTitle, autofocusTitle = external.autofocusTitle,
@@ -453,12 +460,28 @@ class EventEditViewModel @Inject constructor(
fun setLocation(value: String) = update { it.copy(location = value) } fun setLocation(value: String) = update { it.copy(location = value) }
fun setDescription(value: String) = update { it.copy(description = value) } fun setDescription(value: String) = update { it.copy(description = value) }
fun setAllDay(value: Boolean) { 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 // The default reminder differs for all-day vs timed; re-apply the
// type-appropriate default unless the user has hand-edited it (guarded). // type-appropriate default unless the user has hand-edited it (guarded).
applyDefaultReminder() 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 * Switching calendars drops any chosen colour: a palette key is
* account-scoped, and a raw colour may be invalid on the new calendar. * account-scoped, and a raw colour may be invalid on the new calendar.

View File

@@ -99,6 +99,15 @@
<string name="event_edit_availability">Availability</string> <string name="event_edit_availability">Availability</string>
<string name="event_edit_visibility">Visibility</string> <string name="event_edit_visibility">Visibility</string>
<!-- Event form — time zone -->
<string name="event_edit_timezone_device">Device time zone</string>
<string name="event_edit_timezone_device_summary">Follows wherever you are</string>
<string name="event_edit_timezone_search">Search time zones</string>
<string name="event_edit_timezone_recent">Recent</string>
<string name="event_edit_timezone_all">All time zones</string>
<string name="event_edit_timezone_none">No time zone matches “%1$s”</string>
<string name="event_edit_timezone_clear">Use device zone</string>
<!-- Event form — per-event color --> <!-- Event form — per-event color -->
<string name="event_edit_color">Color</string> <string name="event_edit_color">Color</string>
<string name="event_edit_color_default">Calendar color</string> <string name="event_edit_color_default">Calendar color</string>