Compare commits
3 Commits
bda002684c
...
974185f354
| Author | SHA1 | Date | |
|---|---|---|---|
| 974185f354 | |||
| 6aacdd9111 | |||
| 4f263d00fe |
@@ -33,6 +33,7 @@ import de.jeanlucmakiola.calendula.ui.common.viewBaseStack
|
|||||||
import de.jeanlucmakiola.calendula.ui.day.DayScreen
|
import de.jeanlucmakiola.calendula.ui.day.DayScreen
|
||||||
import de.jeanlucmakiola.calendula.ui.detail.EventDetailScreen
|
import de.jeanlucmakiola.calendula.ui.detail.EventDetailScreen
|
||||||
import de.jeanlucmakiola.calendula.ui.edit.EventEditScreen
|
import de.jeanlucmakiola.calendula.ui.edit.EventEditScreen
|
||||||
|
import de.jeanlucmakiola.calendula.ui.edit.ImportSource
|
||||||
import de.jeanlucmakiola.calendula.ui.imports.ImportScreen
|
import de.jeanlucmakiola.calendula.ui.imports.ImportScreen
|
||||||
import de.jeanlucmakiola.calendula.ui.month.MonthScreen
|
import de.jeanlucmakiola.calendula.ui.month.MonthScreen
|
||||||
import de.jeanlucmakiola.calendula.ui.search.SearchScreen
|
import de.jeanlucmakiola.calendula.ui.search.SearchScreen
|
||||||
@@ -172,6 +173,9 @@ fun CalendarHost(
|
|||||||
// picker (many). A plain conditional overlay (no slide) — it's transient.
|
// picker (many). A plain conditional overlay (no slide) — it's transient.
|
||||||
var importUri by remember { mutableStateOf<android.net.Uri?>(null) }
|
var importUri by remember { mutableStateOf<android.net.Uri?>(null) }
|
||||||
var importForm by remember { mutableStateOf<EventForm?>(null) }
|
var importForm by remember { mutableStateOf<EventForm?>(null) }
|
||||||
|
// Which channel filled [importForm]: an .ics file (prompt to apply the default
|
||||||
|
// reminder) or an ACTION_INSERT intent (apply it automatically) — #49.
|
||||||
|
var importFormSource by remember { mutableStateOf(ImportSource.File) }
|
||||||
// A restore (in-app "Restore from .ics" button) always runs the full import
|
// A restore (in-app "Restore from .ics" button) always runs the full import
|
||||||
// flow — picker + summary — even for a single-event file, because the intent
|
// flow — picker + summary — even for a single-event file, because the intent
|
||||||
// is "restore a backup", not "add this one event". An externally opened .ics
|
// is "restore a backup", not "add this one event". An externally opened .ics
|
||||||
@@ -190,6 +194,7 @@ fun CalendarHost(
|
|||||||
// reveals on top of whatever was open without extra dismissal.
|
// reveals on top of whatever was open without extra dismissal.
|
||||||
LaunchedEffect(requestedInsertForm) {
|
LaunchedEffect(requestedInsertForm) {
|
||||||
if (requestedInsertForm != null) {
|
if (requestedInsertForm != null) {
|
||||||
|
importFormSource = ImportSource.Insert
|
||||||
importForm = requestedInsertForm
|
importForm = requestedInsertForm
|
||||||
onInsertConsumed()
|
onInsertConsumed()
|
||||||
}
|
}
|
||||||
@@ -435,6 +440,7 @@ fun CalendarHost(
|
|||||||
onClose = { importUri = null },
|
onClose = { importUri = null },
|
||||||
onOpenSingle = { form ->
|
onOpenSingle = { form ->
|
||||||
importUri = null
|
importUri = null
|
||||||
|
importFormSource = ImportSource.File
|
||||||
importForm = form
|
importForm = form
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
@@ -443,6 +449,7 @@ fun CalendarHost(
|
|||||||
EventEditScreen(
|
EventEditScreen(
|
||||||
initialDateIso = null,
|
initialDateIso = null,
|
||||||
initialForm = form,
|
initialForm = form,
|
||||||
|
initialFormSource = importFormSource,
|
||||||
onClose = { importForm = null },
|
onClose = { importForm = null },
|
||||||
onSaved = { importForm = null },
|
onSaved = { importForm = null },
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -89,6 +89,7 @@ import androidx.compose.ui.graphics.isSpecified
|
|||||||
import androidx.compose.ui.graphics.Shape
|
import androidx.compose.ui.graphics.Shape
|
||||||
import androidx.compose.ui.graphics.vector.ImageVector
|
import androidx.compose.ui.graphics.vector.ImageVector
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.res.pluralStringResource
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.text.AnnotatedString
|
import androidx.compose.ui.text.AnnotatedString
|
||||||
import androidx.compose.ui.text.TextStyle
|
import androidx.compose.ui.text.TextStyle
|
||||||
@@ -182,12 +183,14 @@ fun EventEditScreen(
|
|||||||
editKey: LongArray? = null,
|
editKey: LongArray? = null,
|
||||||
initialStartMinutes: Int? = null,
|
initialStartMinutes: Int? = null,
|
||||||
initialForm: EventForm? = null,
|
initialForm: EventForm? = null,
|
||||||
|
initialFormSource: ImportSource = ImportSource.File,
|
||||||
viewModel: EventEditViewModel = hiltViewModel(),
|
viewModel: EventEditViewModel = hiltViewModel(),
|
||||||
) {
|
) {
|
||||||
LaunchedEffect(initialDateIso, editKey, initialForm) {
|
LaunchedEffect(initialDateIso, editKey, initialForm) {
|
||||||
when {
|
when {
|
||||||
// Single-event .ics open: the form arrives prefilled for review.
|
// A prefilled open: a single-event .ics for review, or an external
|
||||||
initialForm != null -> viewModel.openImported(initialForm)
|
// ACTION_INSERT intent. The source drives how reminders are seeded.
|
||||||
|
initialForm != null -> viewModel.openImported(initialForm, initialFormSource)
|
||||||
editKey != null -> viewModel.openForEdit(
|
editKey != null -> viewModel.openForEdit(
|
||||||
eventId = editKey[0],
|
eventId = editKey[0],
|
||||||
beginMillis = editKey[1],
|
beginMillis = editKey[1],
|
||||||
@@ -202,6 +205,7 @@ fun EventEditScreen(
|
|||||||
}
|
}
|
||||||
val state by viewModel.state.collectAsStateWithLifecycle()
|
val state by viewModel.state.collectAsStateWithLifecycle()
|
||||||
val loadFailed by viewModel.loadFailed.collectAsStateWithLifecycle()
|
val loadFailed by viewModel.loadFailed.collectAsStateWithLifecycle()
|
||||||
|
val importReminderPrompt by viewModel.importReminderPrompt.collectAsStateWithLifecycle()
|
||||||
|
|
||||||
// The form is intentionally forgotten on every close (cancel or save) so
|
// The form is intentionally forgotten on every close (cancel or save) so
|
||||||
// the next open starts clean; it survives rotation because openNew /
|
// the next open starts clean; it survives rotation because openNew /
|
||||||
@@ -332,6 +336,56 @@ fun EventEditScreen(
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A .ics import respects the file's reminders, but offers to swap in the
|
||||||
|
// configured default rather than silently deciding for the user (#49).
|
||||||
|
importReminderPrompt?.let { prompt ->
|
||||||
|
ImportReminderPromptDialog(
|
||||||
|
currentReminderCount = prompt.currentReminderCount,
|
||||||
|
onApply = viewModel::applyImportedReminderDefault,
|
||||||
|
onKeep = viewModel::dismissImportedReminderPrompt,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Offer to apply the settings default reminder to an event opened from a `.ics`
|
||||||
|
* file. The file's own reminders are kept unless the user accepts. A plain
|
||||||
|
* two-choice confirmation, so an [AlertDialog] (not a full-screen picker).
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
private fun ImportReminderPromptDialog(
|
||||||
|
currentReminderCount: Int,
|
||||||
|
onApply: () -> Unit,
|
||||||
|
onKeep: () -> Unit,
|
||||||
|
) {
|
||||||
|
AlertDialog(
|
||||||
|
onDismissRequest = onKeep,
|
||||||
|
title = { Text(stringResource(R.string.import_reminder_prompt_title)) },
|
||||||
|
text = {
|
||||||
|
Text(
|
||||||
|
if (currentReminderCount == 0) {
|
||||||
|
stringResource(R.string.import_reminder_prompt_body_none)
|
||||||
|
} else {
|
||||||
|
pluralStringResource(
|
||||||
|
R.plurals.import_reminder_prompt_body_existing,
|
||||||
|
currentReminderCount,
|
||||||
|
currentReminderCount,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
},
|
||||||
|
confirmButton = {
|
||||||
|
TextButton(onClick = onApply) {
|
||||||
|
Text(stringResource(R.string.import_reminder_prompt_apply))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
dismissButton = {
|
||||||
|
TextButton(onClick = onKeep) {
|
||||||
|
Text(stringResource(R.string.import_reminder_prompt_keep))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -50,6 +50,36 @@ import kotlin.time.Duration.Companion.hours
|
|||||||
import kotlin.time.Instant
|
import kotlin.time.Instant
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Where a prefilled [EventEditViewModel.openImported] form came from — the two
|
||||||
|
* sources want different reminder handling (#49).
|
||||||
|
*/
|
||||||
|
enum class ImportSource {
|
||||||
|
/**
|
||||||
|
* An external `ACTION_INSERT` intent (another app/widget, e.g. Google Maps).
|
||||||
|
* It carries no reminders of its own, so the settings default is applied
|
||||||
|
* automatically, exactly like an in-app new event.
|
||||||
|
*/
|
||||||
|
Insert,
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A parsed single-event `.ics` file. Its own reminders are respected; the
|
||||||
|
* settings default is offered through [EventEditViewModel.importReminderPrompt]
|
||||||
|
* rather than silently applied or suppressed.
|
||||||
|
*/
|
||||||
|
File,
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A pending offer to swap an imported `.ics` event's reminders for the settings
|
||||||
|
* default (#49). [currentReminderCount] is what the file carried (0 or more);
|
||||||
|
* [defaultReminders] is what accepting would set.
|
||||||
|
*/
|
||||||
|
data class ImportReminderPrompt(
|
||||||
|
val currentReminderCount: Int,
|
||||||
|
val defaultReminders: List<Int>,
|
||||||
|
)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Holds the event form being composed. The form's calendar id resolves to
|
* Holds the event form being composed. The form's calendar id resolves to
|
||||||
* (user pick > last used > first writable); the resolved value is what the UI
|
* (user pick > last used > first writable); the resolved value is what the UI
|
||||||
@@ -78,10 +108,16 @@ class EventEditViewModel @Inject constructor(
|
|||||||
// freezes the auto-applied default: switching calendars no longer overwrites
|
// freezes the auto-applied default: switching calendars no longer overwrites
|
||||||
// their choice. Reset with the form.
|
// their choice. Reset with the form.
|
||||||
private val _remindersTouched = MutableStateFlow(false)
|
private val _remindersTouched = MutableStateFlow(false)
|
||||||
|
// A one-time offer, raised when a .ics import opens, to replace the file's
|
||||||
|
// reminders with the settings default (#49). Null while there's nothing to ask.
|
||||||
|
private val _importReminderPrompt = MutableStateFlow<ImportReminderPrompt?>(null)
|
||||||
|
|
||||||
/** True when the event to edit couldn't be loaded; the screen closes itself. */
|
/** True when the event to edit couldn't be loaded; the screen closes itself. */
|
||||||
val loadFailed: StateFlow<Boolean> = _loadFailed.asStateFlow()
|
val loadFailed: StateFlow<Boolean> = _loadFailed.asStateFlow()
|
||||||
|
|
||||||
|
/** Pending "apply your default reminder?" offer for a `.ics` import; null when none. */
|
||||||
|
val importReminderPrompt: StateFlow<ImportReminderPrompt?> = _importReminderPrompt.asStateFlow()
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The event being edited plus everything the form saw at load time.
|
* The event being edited plus everything the form saw at load time.
|
||||||
* For recurring events the write scope is chosen at save time; the
|
* For recurring events the write scope is chosen at save time; the
|
||||||
@@ -112,7 +148,17 @@ class EventEditViewModel @Inject constructor(
|
|||||||
val allDay: List<Int>,
|
val allDay: List<Int>,
|
||||||
val timedOverrides: Map<Long, List<Int>>,
|
val timedOverrides: Map<Long, List<Int>>,
|
||||||
val allDayOverrides: Map<Long, List<Int>>,
|
val allDayOverrides: Map<Long, List<Int>>,
|
||||||
)
|
) {
|
||||||
|
/** The default reminders for an event on [calendarId] of the given kind. */
|
||||||
|
fun resolveFor(calendarId: Long?, isAllDay: Boolean): List<Int> = resolveDefaultReminder(
|
||||||
|
timedGlobal = timed,
|
||||||
|
allDayGlobal = allDay,
|
||||||
|
timedOverrides = timedOverrides,
|
||||||
|
allDayOverrides = allDayOverrides,
|
||||||
|
calendarId = calendarId,
|
||||||
|
isAllDay = isAllDay,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
private data class ExternalInputs(
|
private data class ExternalInputs(
|
||||||
val writable: List<CalendarSource>,
|
val writable: List<CalendarSource>,
|
||||||
@@ -241,18 +287,39 @@ class EventEditViewModel @Inject constructor(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Seed a fresh event from a parsed `.ics` file (the single-event "open into
|
* Seed a fresh event from a prefilled [form] — a parsed single-event `.ics`
|
||||||
* the create form" path). [form] already carries the file's fields; its
|
* file ([ImportSource.File]) or an external `ACTION_INSERT` intent (another
|
||||||
* [EventForm.calendarId] is null so the calendar still resolves to the
|
* app/widget creating an event, e.g. Google Maps' "add to calendar";
|
||||||
* last-used/first-writable one, and reminders are frozen as touched so the
|
* [ImportSource.Insert]; #30, #49). [EventForm.calendarId] is null so the
|
||||||
* settings default never overwrites what the file specified. No-op when a
|
* calendar still resolves to the last-used/first-writable one.
|
||||||
* form is already open, so the prefill survives configuration changes.
|
*
|
||||||
|
* Reminders are handled per [source], because the two paths mean different
|
||||||
|
* things by "no reminders":
|
||||||
|
* - [ImportSource.Insert] carries no reminder semantics, so the settings
|
||||||
|
* default is applied automatically like an in-app new event (a form that
|
||||||
|
* somehow did carry reminders keeps them, frozen).
|
||||||
|
* - [ImportSource.File] owns its reminders, so they're frozen as-is; if a
|
||||||
|
* settings default is configured and differs, [importReminderPrompt] offers
|
||||||
|
* to swap it in rather than silently deciding for the user.
|
||||||
|
*
|
||||||
|
* No-op when a form is already open, so the prefill survives configuration
|
||||||
|
* changes.
|
||||||
*/
|
*/
|
||||||
fun openImported(form: EventForm) {
|
fun openImported(form: EventForm, source: ImportSource) {
|
||||||
if (_form.value != null || _editTarget.value != null) return
|
if (_form.value != null || _editTarget.value != null) return
|
||||||
_remindersTouched.value = true
|
|
||||||
_revealed.value = form.populatedFields()
|
_revealed.value = form.populatedFields()
|
||||||
_form.value = form
|
_form.value = form
|
||||||
|
when (source) {
|
||||||
|
ImportSource.Insert ->
|
||||||
|
if (form.reminders.isNotEmpty()) _remindersTouched.value = true
|
||||||
|
else applyDefaultReminder()
|
||||||
|
|
||||||
|
ImportSource.File -> {
|
||||||
|
// Respect the file's own reminders; never silently overwrite them.
|
||||||
|
_remindersTouched.value = true
|
||||||
|
maybePromptImportedReminderDefault(form)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -266,26 +333,12 @@ class EventEditViewModel @Inject constructor(
|
|||||||
private fun applyDefaultReminder(calendarId: Long? = null) {
|
private fun applyDefaultReminder(calendarId: Long? = null) {
|
||||||
if (_editTarget.value != null || _remindersTouched.value) return
|
if (_editTarget.value != null || _remindersTouched.value) return
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
val defaults = combine(
|
val defaults = reminderDefaults()
|
||||||
settingsPrefs.defaultReminderMinutes,
|
|
||||||
settingsPrefs.defaultAllDayReminderMinutes,
|
|
||||||
settingsPrefs.perCalendarReminderOverride,
|
|
||||||
settingsPrefs.perCalendarAllDayReminderOverride,
|
|
||||||
) { timed, allDay, timedOv, allDayOv ->
|
|
||||||
ReminderDefaults(timed, allDay, timedOv, allDayOv)
|
|
||||||
}.first()
|
|
||||||
val targetId = calendarId ?: resolvedCalendarId.first()
|
val targetId = calendarId ?: resolvedCalendarId.first()
|
||||||
// Re-check after suspending: bail if the form closed or the user edited.
|
// Re-check after suspending: bail if the form closed or the user edited.
|
||||||
val form = _form.value ?: return@launch
|
val form = _form.value ?: return@launch
|
||||||
if (_editTarget.value != null || _remindersTouched.value) return@launch
|
if (_editTarget.value != null || _remindersTouched.value) return@launch
|
||||||
val reminders = resolveDefaultReminder(
|
val reminders = defaults.resolveFor(targetId, form.isAllDay)
|
||||||
timedGlobal = defaults.timed,
|
|
||||||
allDayGlobal = defaults.allDay,
|
|
||||||
timedOverrides = defaults.timedOverrides,
|
|
||||||
allDayOverrides = defaults.allDayOverrides,
|
|
||||||
calendarId = targetId,
|
|
||||||
isAllDay = form.isAllDay,
|
|
||||||
)
|
|
||||||
_form.value = form.copy(reminders = reminders)
|
_form.value = form.copy(reminders = reminders)
|
||||||
// Surface the section so an auto-applied default is visible and
|
// Surface the section so an auto-applied default is visible and
|
||||||
// removable, even when Reminders isn't a default-shown field.
|
// removable, even when Reminders isn't a default-shown field.
|
||||||
@@ -295,6 +348,52 @@ class EventEditViewModel @Inject constructor(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Snapshot the four settings-default reminder flows into one value. */
|
||||||
|
private suspend fun reminderDefaults(): ReminderDefaults = combine(
|
||||||
|
settingsPrefs.defaultReminderMinutes,
|
||||||
|
settingsPrefs.defaultAllDayReminderMinutes,
|
||||||
|
settingsPrefs.perCalendarReminderOverride,
|
||||||
|
settingsPrefs.perCalendarAllDayReminderOverride,
|
||||||
|
) { timed, allDay, timedOv, allDayOv ->
|
||||||
|
ReminderDefaults(timed, allDay, timedOv, allDayOv)
|
||||||
|
}.first()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A `.ics` import respects the file's reminders, but an event opened from a
|
||||||
|
* file often has none while the user still expects their configured default.
|
||||||
|
* Rather than silently deciding, raise a one-time offer to swap in the
|
||||||
|
* settings default — but only when there's a real choice: a default is
|
||||||
|
* configured and it isn't already exactly what the file carried.
|
||||||
|
*/
|
||||||
|
private fun maybePromptImportedReminderDefault(form: EventForm) {
|
||||||
|
viewModelScope.launch {
|
||||||
|
val targetId = resolvedCalendarId.first()
|
||||||
|
val default = reminderDefaults().resolveFor(targetId, form.isAllDay)
|
||||||
|
// Bail if the form closed or became an edit while we resolved.
|
||||||
|
val current = _form.value ?: return@launch
|
||||||
|
if (_editTarget.value != null) return@launch
|
||||||
|
if (default.isEmpty() || default == current.reminders) return@launch
|
||||||
|
_importReminderPrompt.value = ImportReminderPrompt(
|
||||||
|
currentReminderCount = current.reminders.size,
|
||||||
|
defaultReminders = default,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Accept the import prompt: replace the file's reminders with the default. */
|
||||||
|
fun applyImportedReminderDefault() {
|
||||||
|
val prompt = _importReminderPrompt.value ?: return
|
||||||
|
_importReminderPrompt.value = null
|
||||||
|
// Already frozen as touched by openImported; this just swaps the values.
|
||||||
|
update { it.copy(reminders = prompt.defaultReminders) }
|
||||||
|
_revealed.value = _revealed.value + EventFormField.Reminders
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Decline the import prompt: keep the file's own reminders untouched. */
|
||||||
|
fun dismissImportedReminderPrompt() {
|
||||||
|
_importReminderPrompt.value = null
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Load an existing event into the form. [beginMillis]/[endMillis] are the
|
* Load an existing event into the form. [beginMillis]/[endMillis] are the
|
||||||
* tapped occurrence's own times, like on the detail screen. No-op while a
|
* tapped occurrence's own times, like on the detail screen. No-op while a
|
||||||
@@ -329,6 +428,7 @@ class EventEditViewModel @Inject constructor(
|
|||||||
_editTarget.value = null
|
_editTarget.value = null
|
||||||
_loadFailed.value = false
|
_loadFailed.value = false
|
||||||
_remindersTouched.value = false
|
_remindersTouched.value = false
|
||||||
|
_importReminderPrompt.value = null
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Unfold one optional field, picked in the "more fields" dialog. */
|
/** Unfold one optional field, picked in the "more fields" dialog. */
|
||||||
|
|||||||
@@ -117,6 +117,16 @@
|
|||||||
<string name="event_edit_gone_title">Event deleted</string>
|
<string name="event_edit_gone_title">Event deleted</string>
|
||||||
<string name="event_edit_gone_body">This event was deleted in the meantime, for example on another device. Your changes can no longer be saved.</string>
|
<string name="event_edit_gone_body">This event was deleted in the meantime, for example on another device. Your changes can no longer be saved.</string>
|
||||||
|
|
||||||
|
<!-- Event form — apply default reminder to an imported .ics event (#49) -->
|
||||||
|
<string name="import_reminder_prompt_title">Apply your default reminder?</string>
|
||||||
|
<string name="import_reminder_prompt_body_none">This event was imported without any reminder.</string>
|
||||||
|
<plurals name="import_reminder_prompt_body_existing">
|
||||||
|
<item quantity="one">This event was imported with %1$d reminder.</item>
|
||||||
|
<item quantity="other">This event was imported with %1$d reminders.</item>
|
||||||
|
</plurals>
|
||||||
|
<string name="import_reminder_prompt_apply">Apply default</string>
|
||||||
|
<string name="import_reminder_prompt_keep">Keep as-is</string>
|
||||||
|
|
||||||
<!-- Event form — recurrence picker (v1.3) -->
|
<!-- Event form — recurrence picker (v1.3) -->
|
||||||
<string name="event_edit_recurrence_none">Does not repeat</string>
|
<string name="event_edit_recurrence_none">Does not repeat</string>
|
||||||
<string name="event_edit_recurrence_custom">Custom</string>
|
<string name="event_edit_recurrence_custom">Custom</string>
|
||||||
|
|||||||
Reference in New Issue
Block a user