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:
@@ -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<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
|
||||
* 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<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) {
|
||||
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")
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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,
|
||||
|
||||
@@ -27,6 +27,8 @@ data class EventEditUiState(
|
||||
* neither list.
|
||||
*/
|
||||
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). */
|
||||
val isEditing: Boolean = false,
|
||||
/**
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -99,6 +99,15 @@
|
||||
<string name="event_edit_availability">Availability</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 -->
|
||||
<string name="event_edit_color">Color</string>
|
||||
<string name="event_edit_color_default">Calendar color</string>
|
||||
|
||||
Reference in New Issue
Block a user