Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
be1501f6e1 |
@@ -346,6 +346,19 @@ class SettingsPrefs @Inject constructor(
|
||||
store.edit { it[DRAG_TO_RESCHEDULE_KEY] = enabled }
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether tapping an event in the calendar views opens the edit form instead
|
||||
* of the detail screen (#178). Defaults to OFF; read-only events and
|
||||
* reminder notifications always open the detail screen.
|
||||
*/
|
||||
val tapOpensEditor: Flow<Boolean> = store.data.map { prefs ->
|
||||
prefs[TAP_OPENS_EDITOR_KEY] ?: false
|
||||
}
|
||||
|
||||
suspend fun setTapOpensEditor(enabled: Boolean) {
|
||||
store.edit { it[TAP_OPENS_EDITOR_KEY] = enabled }
|
||||
}
|
||||
|
||||
/**
|
||||
* How far ahead the in-app Agenda screen shows events (v2.11). Defaults to
|
||||
* [AgendaRange.Month] — a month of upcoming events. Independent of the
|
||||
@@ -1019,6 +1032,7 @@ class SettingsPrefs @Inject constructor(
|
||||
internal val TIMELINE_SCALE_KEY = stringPreferencesKey("timeline_scale")
|
||||
internal val TODAY_BUTTON_IN_TOOLBAR_KEY = booleanPreferencesKey("today_button_in_toolbar")
|
||||
internal val DRAG_TO_RESCHEDULE_KEY = booleanPreferencesKey("drag_to_reschedule")
|
||||
internal val TAP_OPENS_EDITOR_KEY = booleanPreferencesKey("tap_opens_editor")
|
||||
internal val DEFAULT_VIEW_KEY = stringPreferencesKey("default_view")
|
||||
internal val QUICK_SWITCH_VIEWS_KEY = stringPreferencesKey("quick_switch_views")
|
||||
internal val DRAWER_VIEW_ORDER_KEY = stringPreferencesKey("drawer_view_order")
|
||||
|
||||
@@ -192,7 +192,7 @@ fun CalendarHost(
|
||||
// placeholder — so the destination never loads a bogus id=0 on first frame.
|
||||
var detailKey by rememberSaveable { mutableStateOf<LongArray?>(null) }
|
||||
var heldKey by remember { mutableStateOf<LongArray?>(null) }
|
||||
val onEventClick: (EventInstance) -> Unit = { event ->
|
||||
val onOpenDetail: (EventInstance) -> Unit = { event ->
|
||||
val key = longArrayOf(
|
||||
event.eventId,
|
||||
event.start.toEpochMilliseconds(),
|
||||
@@ -410,6 +410,17 @@ fun CalendarHost(
|
||||
heldEditKey = key
|
||||
editKey = key
|
||||
}
|
||||
// A tap opens the edit form instead of the detail screen when the user chose
|
||||
// so (#178) — only for events whose calendar takes writes.
|
||||
val tapOpensEditor = viewModel.tapOpensEditor.collectAsStateWithLifecycle().value
|
||||
val actionCalendars by eventActions.calendars.collectAsStateWithLifecycle()
|
||||
val onEventClick: (EventInstance) -> Unit = { event ->
|
||||
if (tapOpensEditor && actionCalendars[event.calendarId]?.canModifyContents == true) {
|
||||
onEditEvent(event)
|
||||
} else {
|
||||
onOpenDetail(event)
|
||||
}
|
||||
}
|
||||
// Off by preference (#173) switches off the gesture, not the scope: blocks
|
||||
// register no drag, while the edit form and its TalkBack action stay put.
|
||||
val dragToReschedule = viewModel.dragToReschedule.collectAsStateWithLifecycle().value
|
||||
@@ -705,6 +716,10 @@ fun CalendarHost(
|
||||
detailKey = null
|
||||
},
|
||||
onManageCalendars = { showCalendars = true },
|
||||
onDuplicate = { form ->
|
||||
importFormSource = ImportSource.Insert
|
||||
importForm = form
|
||||
},
|
||||
visible = editKey != null,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -78,4 +78,12 @@ class CalendarHostViewModel @Inject constructor(
|
||||
started = SharingStarted.WhileSubscribed(5_000L),
|
||||
initialValue = false,
|
||||
)
|
||||
|
||||
/** Whether tapping an event opens the edit form rather than the detail screen (#178). */
|
||||
val tapOpensEditor: StateFlow<Boolean> = prefs.tapOpensEditor
|
||||
.stateIn(
|
||||
scope = viewModelScope,
|
||||
started = SharingStarted.WhileSubscribed(5_000L),
|
||||
initialValue = false,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -36,6 +36,15 @@ import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.Notes
|
||||
import androidx.compose.material.icons.filled.Add
|
||||
import kotlinx.coroutines.launch
|
||||
import de.jeanlucmakiola.calendula.ui.common.sendEventShare
|
||||
import de.jeanlucmakiola.calendula.ui.common.rememberWriteGate
|
||||
import de.jeanlucmakiola.calendula.ui.common.DeleteEventDialog
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.material.icons.filled.Share
|
||||
import androidx.compose.material.icons.filled.MoreVert
|
||||
import androidx.compose.material.icons.filled.Delete
|
||||
import androidx.compose.material.icons.filled.ContentCopy
|
||||
import androidx.compose.material.icons.filled.ArrowDropDown
|
||||
import androidx.compose.material.icons.filled.CalendarMonth
|
||||
import androidx.compose.material.icons.filled.Close
|
||||
@@ -192,6 +201,10 @@ import kotlin.time.Clock
|
||||
* first asks how far the change reaches (this occurrence / this and
|
||||
* following / whole series) — then closes. The calendar picker offers only
|
||||
* writable calendars; when editing, the calendar is fixed.
|
||||
*
|
||||
* Leaving with unsaved changes asks to save, discard or keep editing (#178).
|
||||
* While editing, the overflow menu offers Duplicate (the form as it stands,
|
||||
* handed to [onDuplicate] as a new event), Share and Delete.
|
||||
*/
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
@@ -205,6 +218,8 @@ fun EventEditScreen(
|
||||
initialForm: EventForm? = null,
|
||||
initialFormSource: ImportSource = ImportSource.File,
|
||||
onManageCalendars: (() -> Unit)? = null,
|
||||
onDuplicate: ((EventForm) -> Unit)? = null,
|
||||
onDeleted: () -> Unit = onSaved,
|
||||
visible: Boolean = true,
|
||||
viewModel: EventEditViewModel = hiltViewModel(),
|
||||
) {
|
||||
@@ -236,6 +251,12 @@ fun EventEditScreen(
|
||||
viewModel.reset()
|
||||
onClose()
|
||||
}
|
||||
val hasUnsavedChanges by viewModel.hasUnsavedChanges.collectAsStateWithLifecycle()
|
||||
var showLeavePrompt by rememberSaveable { mutableStateOf(false) }
|
||||
var showDeleteDialog by rememberSaveable { mutableStateOf(false) }
|
||||
val requestClose = {
|
||||
if (hasUnsavedChanges) showLeavePrompt = true else close()
|
||||
}
|
||||
// The event vanished between the detail screen and the edit tap — fall
|
||||
// back to the detail screen, which shows its own failure state.
|
||||
LaunchedEffect(loadFailed) {
|
||||
@@ -263,14 +284,42 @@ fun EventEditScreen(
|
||||
}
|
||||
}
|
||||
|
||||
val scope = rememberCoroutineScope()
|
||||
val requireWrite = rememberWriteGate()
|
||||
val shareFailedMessage = stringResource(R.string.event_share_failed)
|
||||
val shareChooserTitle = stringResource(R.string.event_share_chooser_title)
|
||||
val onShareClick: () -> Unit = {
|
||||
scope.launch {
|
||||
val uri = viewModel.shareUri()
|
||||
val sent = uri != null && context.sendEventShare(uri, shareChooserTitle)
|
||||
if (!sent) snackbarHostState.showSnackbar(shareFailedMessage)
|
||||
}
|
||||
}
|
||||
// The copy opens over this form; a form with nothing to lose steps aside.
|
||||
val onDuplicateClick: () -> Unit = {
|
||||
viewModel.duplicateForm()?.let { form ->
|
||||
onDuplicate?.invoke(form)
|
||||
if (!hasUnsavedChanges) close()
|
||||
}
|
||||
}
|
||||
|
||||
val saveFailedMessage = stringResource(R.string.event_edit_save_failed)
|
||||
val writeDeniedMessage = stringResource(R.string.event_edit_write_denied)
|
||||
val deleteFailedMessage = stringResource(R.string.event_delete_failed)
|
||||
LaunchedEffect(state?.saveState) {
|
||||
when (state?.saveState) {
|
||||
SaveUiState.Saved -> {
|
||||
viewModel.reset()
|
||||
onSaved()
|
||||
}
|
||||
SaveUiState.Deleted -> {
|
||||
viewModel.reset()
|
||||
onDeleted()
|
||||
}
|
||||
SaveUiState.DeleteFailed -> {
|
||||
viewModel.consumeSaveResult()
|
||||
snackbarHostState.showSnackbar(deleteFailedMessage, duration = SnackbarDuration.Long)
|
||||
}
|
||||
// A failed save leaves the form looking unchanged, so the snackbar is
|
||||
// the only sign anything happened — long rather than the default
|
||||
// flash (Codeberg #234: it read as "nothing happens at all").
|
||||
@@ -288,7 +337,7 @@ fun EventEditScreen(
|
||||
}
|
||||
|
||||
Scaffold(
|
||||
modifier = Modifier.predictiveBack(onBack = close, visible = visible),
|
||||
modifier = Modifier.predictiveBack(onBack = requestClose, visible = visible),
|
||||
snackbarHost = { SnackbarHost(snackbarHostState) },
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
@@ -296,7 +345,7 @@ fun EventEditScreen(
|
||||
// matching the detail screen's bare top bar.
|
||||
title = {},
|
||||
navigationIcon = {
|
||||
IconButton(onClick = close) {
|
||||
IconButton(onClick = requestClose) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Close,
|
||||
contentDescription = stringResource(R.string.event_edit_close),
|
||||
@@ -304,6 +353,15 @@ fun EventEditScreen(
|
||||
}
|
||||
},
|
||||
actions = {
|
||||
if (state?.isEditing == true) {
|
||||
EditActionsMenu(
|
||||
canDelete = state?.isManaged == false,
|
||||
canDuplicate = onDuplicate != null,
|
||||
onDuplicate = onDuplicateClick,
|
||||
onShare = onShareClick,
|
||||
onDelete = { requireWrite { showDeleteDialog = true } },
|
||||
)
|
||||
}
|
||||
Button(
|
||||
onClick = onSaveClick,
|
||||
enabled = state != null && state?.saveState != SaveUiState.Saving,
|
||||
@@ -330,6 +388,32 @@ fun EventEditScreen(
|
||||
}
|
||||
}
|
||||
|
||||
if (showLeavePrompt) {
|
||||
UnsavedChangesDialog(
|
||||
onSave = {
|
||||
showLeavePrompt = false
|
||||
onSaveClick()
|
||||
},
|
||||
onDiscard = {
|
||||
showLeavePrompt = false
|
||||
close()
|
||||
},
|
||||
onDismiss = { showLeavePrompt = false },
|
||||
)
|
||||
}
|
||||
|
||||
val recurs = viewModel.editedEventRecurs()
|
||||
if (showDeleteDialog && recurs != null) {
|
||||
DeleteEventDialog(
|
||||
isRecurring = recurs,
|
||||
onConfirm = { deleteScope ->
|
||||
showDeleteDialog = false
|
||||
viewModel.delete(deleteScope)
|
||||
},
|
||||
onDismiss = { showDeleteDialog = false },
|
||||
)
|
||||
}
|
||||
|
||||
if (state?.saveState == SaveUiState.AwaitingScope) {
|
||||
SaveScopeDialog(
|
||||
recurrenceChanged = state?.recurrenceChanged == true,
|
||||
@@ -374,6 +458,90 @@ fun EventEditScreen(
|
||||
}
|
||||
}
|
||||
|
||||
/** Duplicate, Share and Delete for the event being edited, behind an overflow button. */
|
||||
@Composable
|
||||
private fun EditActionsMenu(
|
||||
canDelete: Boolean,
|
||||
canDuplicate: Boolean,
|
||||
onDuplicate: () -> Unit,
|
||||
onShare: () -> Unit,
|
||||
onDelete: () -> Unit,
|
||||
) {
|
||||
var open by remember { mutableStateOf(false) }
|
||||
Box {
|
||||
IconButton(onClick = { open = true }) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.MoreVert,
|
||||
contentDescription = stringResource(R.string.event_edit_more_actions),
|
||||
)
|
||||
}
|
||||
DropdownMenu(expanded = open, onDismissRequest = { open = false }) {
|
||||
if (canDuplicate) {
|
||||
DropdownMenuItem(
|
||||
text = { Text(stringResource(R.string.event_detail_duplicate)) },
|
||||
leadingIcon = { Icon(Icons.Default.ContentCopy, contentDescription = null) },
|
||||
onClick = {
|
||||
open = false
|
||||
onDuplicate()
|
||||
},
|
||||
)
|
||||
}
|
||||
DropdownMenuItem(
|
||||
text = { Text(stringResource(R.string.event_detail_share)) },
|
||||
leadingIcon = { Icon(Icons.Default.Share, contentDescription = null) },
|
||||
onClick = {
|
||||
open = false
|
||||
onShare()
|
||||
},
|
||||
)
|
||||
// Managed special-dates events have no delete, as on the detail screen.
|
||||
if (canDelete) {
|
||||
DropdownMenuItem(
|
||||
text = { Text(stringResource(R.string.event_detail_delete)) },
|
||||
leadingIcon = { Icon(Icons.Default.Delete, contentDescription = null) },
|
||||
onClick = {
|
||||
open = false
|
||||
onDelete()
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Asked when leaving the form with unsaved changes (#178). Dismissing keeps
|
||||
* editing; a form with nothing changed closes without asking.
|
||||
*/
|
||||
@Composable
|
||||
private fun UnsavedChangesDialog(
|
||||
onSave: () -> Unit,
|
||||
onDiscard: () -> Unit,
|
||||
onDismiss: () -> Unit,
|
||||
) {
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
title = { Text(stringResource(R.string.event_edit_unsaved_title)) },
|
||||
text = {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
OptionCard(
|
||||
label = stringResource(R.string.event_edit_unsaved_save),
|
||||
onClick = onSave,
|
||||
)
|
||||
OptionCard(
|
||||
label = stringResource(R.string.event_edit_unsaved_discard),
|
||||
onClick = onDiscard,
|
||||
)
|
||||
}
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(onClick = onDismiss) {
|
||||
Text(stringResource(R.string.event_edit_unsaved_keep))
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
|
||||
@@ -77,6 +77,9 @@ sealed interface SaveUiState {
|
||||
data object Gone : SaveUiState
|
||||
data object Saving : SaveUiState
|
||||
data object Saved : SaveUiState
|
||||
/** The event was deleted from the form (#178). */
|
||||
data object Deleted : SaveUiState
|
||||
data object DeleteFailed : SaveUiState
|
||||
/** WRITE_CALENDAR was revoked between the tap and the provider call. */
|
||||
data object NeedsPermission : SaveUiState
|
||||
data object Failed : SaveUiState
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package de.jeanlucmakiola.calendula.ui.edit
|
||||
|
||||
import android.net.Uri
|
||||
import android.util.Log
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
@@ -7,6 +8,7 @@ import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import de.jeanlucmakiola.calendula.data.calendar.CalendarRepository
|
||||
import de.jeanlucmakiola.calendula.data.calendar.NoSuchEventException
|
||||
import de.jeanlucmakiola.calendula.data.di.IoDispatcher
|
||||
import de.jeanlucmakiola.calendula.data.ics.IcsExporter
|
||||
import de.jeanlucmakiola.calendula.data.prefs.CalendarPrefs
|
||||
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
|
||||
import de.jeanlucmakiola.calendula.data.prefs.firstDayOfWeek
|
||||
@@ -18,6 +20,7 @@ import de.jeanlucmakiola.calendula.domain.CalendarSource
|
||||
import de.jeanlucmakiola.calendula.domain.EditSnapshot
|
||||
import de.jeanlucmakiola.calendula.domain.EventAttendee
|
||||
import de.jeanlucmakiola.calendula.domain.EventColorOption
|
||||
import de.jeanlucmakiola.calendula.domain.EventDetail
|
||||
import de.jeanlucmakiola.calendula.domain.EventForm
|
||||
import de.jeanlucmakiola.calendula.domain.EventFormField
|
||||
import de.jeanlucmakiola.calendula.domain.RecurringWriteScope
|
||||
@@ -26,6 +29,8 @@ import de.jeanlucmakiola.calendula.domain.populatedFields
|
||||
import de.jeanlucmakiola.calendula.domain.problems
|
||||
import de.jeanlucmakiola.calendula.domain.toEditSnapshot
|
||||
import de.jeanlucmakiola.calendula.ui.detail.EventDetailViewModel.Companion.NO_OCCURRENCE_TIME
|
||||
import de.jeanlucmakiola.calendula.ui.common.deleteEvent
|
||||
import de.jeanlucmakiola.calendula.ui.common.stageEventShare
|
||||
import de.jeanlucmakiola.calendula.ui.common.wallClockTime
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
@@ -133,6 +138,7 @@ class EventEditViewModel @Inject constructor(
|
||||
private val repository: CalendarRepository,
|
||||
private val prefs: CalendarPrefs,
|
||||
private val settingsPrefs: SettingsPrefs,
|
||||
private val icsExporter: IcsExporter,
|
||||
@IoDispatcher private val io: CoroutineDispatcher,
|
||||
) : ViewModel() {
|
||||
|
||||
@@ -157,6 +163,9 @@ class EventEditViewModel @Inject constructor(
|
||||
// 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)
|
||||
// Set by the first change the user makes, so a form only holding its own
|
||||
// defaults or an import's prefill can be left without asking.
|
||||
private val _edited = MutableStateFlow(false)
|
||||
|
||||
/** True when the event to edit couldn't be loaded; the screen closes itself. */
|
||||
val loadFailed: StateFlow<Boolean> = _loadFailed.asStateFlow()
|
||||
@@ -177,6 +186,8 @@ class EventEditViewModel @Inject constructor(
|
||||
val beginMillis: Long,
|
||||
val endMillis: Long,
|
||||
val zone: TimeZone,
|
||||
/** The event as loaded, on the tapped occurrence's times — what Share exports. */
|
||||
val detail: EventDetail,
|
||||
) {
|
||||
val original: EventForm get() = snapshot.form
|
||||
}
|
||||
@@ -312,6 +323,18 @@ class EventEditViewModel @Inject constructor(
|
||||
initialValue = null,
|
||||
)
|
||||
|
||||
/**
|
||||
* Whether leaving now would lose something the user did (#178): an edit that
|
||||
* differs from the loaded event, or any change to a new or imported form.
|
||||
*/
|
||||
val hasUnsavedChanges: StateFlow<Boolean> = combine(_form, _editTarget, _edited) { form, target, edited ->
|
||||
when {
|
||||
form == null -> false
|
||||
target != null -> form != target.original
|
||||
else -> edited
|
||||
}
|
||||
}.stateIn(viewModelScope, SharingStarted.Eagerly, false)
|
||||
|
||||
/**
|
||||
* First day of the week for ordering the recurrence weekday toggles — the
|
||||
* app's "week starts on" preference, matching the calendar views (Auto
|
||||
@@ -531,7 +554,13 @@ class EventEditViewModel @Inject constructor(
|
||||
val end = endMillis.takeUnless { it == NO_OCCURRENCE_TIME }
|
||||
?: detail.instance.end.toEpochMilliseconds()
|
||||
val snapshot = detail.toEditSnapshot(begin, end, zone)
|
||||
_editTarget.value = EditTarget(eventId, snapshot, begin, end, zone)
|
||||
val occurrence = detail.copy(
|
||||
instance = detail.instance.copy(
|
||||
start = Instant.fromEpochMilliseconds(begin),
|
||||
end = Instant.fromEpochMilliseconds(end),
|
||||
),
|
||||
)
|
||||
_editTarget.value = EditTarget(eventId, snapshot, begin, end, zone, occurrence)
|
||||
// Sections holding data must show even when not in the defaults.
|
||||
_revealed.value = snapshot.form.populatedFields()
|
||||
_form.value = snapshot.form
|
||||
@@ -549,6 +578,7 @@ class EventEditViewModel @Inject constructor(
|
||||
_remindersTouched.value = false
|
||||
_durationTouched.value = false
|
||||
_importReminderPrompt.value = null
|
||||
_edited.value = false
|
||||
}
|
||||
|
||||
/** Unfold one optional field, picked in the "more fields" dialog. */
|
||||
@@ -559,24 +589,24 @@ class EventEditViewModel @Inject constructor(
|
||||
// The title field wraps (multi-line) so long titles stay visible (#33), but
|
||||
// a title is one logical line: drop any newline the IME's Enter key or a
|
||||
// paste would introduce, so it never reaches the provider's TITLE column.
|
||||
fun setTitle(value: String) = update {
|
||||
fun setTitle(value: String) = edit {
|
||||
val cleaned = value.replace("\n", "").replace("\r", "")
|
||||
it.copy(title = capLength(cleaned, it.title))
|
||||
}
|
||||
// The location wraps too (#273) but is likewise one logical line. A pasted
|
||||
// multi-line address joins the way the contact picker's does (#146), so it
|
||||
// never runs together into "12 Main St10115 Berlin".
|
||||
fun setLocation(value: String) = update {
|
||||
fun setLocation(value: String) = edit {
|
||||
val cleaned = value.replace(LINE_BREAK, ", ")
|
||||
it.copy(location = capLength(cleaned, it.location))
|
||||
}
|
||||
fun setDescription(value: String) = update { it.copy(description = value) }
|
||||
fun setDescription(value: String) = edit { it.copy(description = value) }
|
||||
fun setAllDay(value: Boolean) {
|
||||
// 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) }
|
||||
edit { 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()
|
||||
@@ -591,7 +621,7 @@ class EventEditViewModel @Inject constructor(
|
||||
* time — only real picks are remembered, not the device fallback.
|
||||
*/
|
||||
fun setTimezone(zoneId: String?) {
|
||||
update { it.copy(timezone = zoneId) }
|
||||
edit { it.copy(timezone = zoneId) }
|
||||
if (zoneId != null) {
|
||||
viewModelScope.launch { withContext(io) { settingsPrefs.addRecentTimeZone(zoneId) } }
|
||||
}
|
||||
@@ -603,35 +633,35 @@ class EventEditViewModel @Inject constructor(
|
||||
* The event falls back to the new calendar's colour until re-picked.
|
||||
*/
|
||||
fun setCalendar(id: Long) {
|
||||
update { it.copy(calendarId = id, colorKey = null, color = null) }
|
||||
edit { it.copy(calendarId = id, colorKey = null, color = null) }
|
||||
// A fresh event re-inherits the new calendar's default reminder and
|
||||
// length unless the user has already hand-edited them (guarded inside).
|
||||
applyDefaultReminder(id)
|
||||
applyDefaultDuration(id)
|
||||
}
|
||||
fun setAvailability(value: Availability) = update { it.copy(availability = value) }
|
||||
fun setAccessLevel(value: AccessLevel) = update { it.copy(accessLevel = value) }
|
||||
fun setAvailability(value: Availability) = edit { it.copy(availability = value) }
|
||||
fun setAccessLevel(value: AccessLevel) = edit { it.copy(accessLevel = value) }
|
||||
|
||||
/** Pick a palette colour (round-trips via its key); [argb] is its swatch. */
|
||||
fun setColorKey(key: String, argb: Int) = update { it.copy(colorKey = key, color = argb) }
|
||||
fun setColorKey(key: String, argb: Int) = edit { it.copy(colorKey = key, color = argb) }
|
||||
|
||||
/** Pick a raw colour (local / opted-in calendars), clearing any palette key. */
|
||||
fun setColorRaw(argb: Int) = update { it.copy(colorKey = null, color = argb) }
|
||||
fun setColorRaw(argb: Int) = edit { it.copy(colorKey = null, color = argb) }
|
||||
|
||||
/** Clear the colour so the event inherits its calendar's. */
|
||||
fun clearColor() = update { it.copy(colorKey = null, color = null) }
|
||||
fun clearColor() = edit { it.copy(colorKey = null, color = null) }
|
||||
|
||||
/** Bare RRULE value from the recurrence picker; null = does not repeat. */
|
||||
fun setRecurrence(rrule: String?) = update { it.copy(rrule = rrule) }
|
||||
fun setRecurrence(rrule: String?) = edit { it.copy(rrule = rrule) }
|
||||
|
||||
fun addReminder(minutes: Int) {
|
||||
_remindersTouched.value = true
|
||||
update { it.copy(reminders = (it.reminders + minutes).distinct().sorted()) }
|
||||
edit { it.copy(reminders = (it.reminders + minutes).distinct().sorted()) }
|
||||
}
|
||||
|
||||
fun removeReminder(minutes: Int) {
|
||||
_remindersTouched.value = true
|
||||
update { it.copy(reminders = it.reminders - minutes) }
|
||||
edit { it.copy(reminders = it.reminders - minutes) }
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -642,7 +672,7 @@ class EventEditViewModel @Inject constructor(
|
||||
fun addAttendee(email: String, name: String = "", optional: Boolean = false) {
|
||||
val trimmed = email.trim()
|
||||
if (trimmed.isEmpty()) return
|
||||
update { form ->
|
||||
edit { form ->
|
||||
val attendee = EventAttendee(email = trimmed, name = name.trim(), optional = optional)
|
||||
val rest = form.attendees.filterNot { it.email.equals(trimmed, ignoreCase = true) }
|
||||
form.copy(attendees = rest + attendee)
|
||||
@@ -650,10 +680,10 @@ class EventEditViewModel @Inject constructor(
|
||||
}
|
||||
|
||||
fun removeAttendee(attendee: EventAttendee) =
|
||||
update { it.copy(attendees = it.attendees.filterNot { a -> a.email.equals(attendee.email, ignoreCase = true) }) }
|
||||
edit { it.copy(attendees = it.attendees.filterNot { a -> a.email.equals(attendee.email, ignoreCase = true) }) }
|
||||
|
||||
/** Flip a guest between required and optional (the per-row role toggle). */
|
||||
fun setAttendeeOptional(attendee: EventAttendee, optional: Boolean) = update { form ->
|
||||
fun setAttendeeOptional(attendee: EventAttendee, optional: Boolean) = edit { form ->
|
||||
form.copy(
|
||||
attendees = form.attendees.map { a ->
|
||||
if (a.email.equals(attendee.email, ignoreCase = true)) a.copy(optional = optional) else a
|
||||
@@ -669,12 +699,12 @@ class EventEditViewModel @Inject constructor(
|
||||
// default duration, so a later calendar switch keeps their span (#54).
|
||||
fun setEndDate(date: LocalDate) {
|
||||
_durationTouched.value = true
|
||||
update { it.copy(end = LocalDateTime(date, it.end.time)) }
|
||||
edit { it.copy(end = LocalDateTime(date, it.end.time)) }
|
||||
}
|
||||
|
||||
fun setEndTime(time: LocalTime) {
|
||||
_durationTouched.value = true
|
||||
update { it.copy(end = LocalDateTime(it.end.date, time)) }
|
||||
edit { it.copy(end = LocalDateTime(it.end.date, time)) }
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -810,12 +840,46 @@ class EventEditViewModel @Inject constructor(
|
||||
}
|
||||
}
|
||||
|
||||
/** Whether the event being edited recurs; null while not editing. */
|
||||
fun editedEventRecurs(): Boolean? = _editTarget.value?.let { it.original.rrule != null }
|
||||
|
||||
/** The form as it stands, as a fresh one-off event — Duplicate in the edit form (#178). */
|
||||
fun duplicateForm(): EventForm? = state.value?.form?.copy(rrule = null)
|
||||
|
||||
/** A staged `.ics` of the event as saved, or null when it can't be built. */
|
||||
suspend fun shareUri(): Uri? {
|
||||
val detail = _editTarget.value?.detail ?: return null
|
||||
return withContext(io) { icsExporter.stageEventShare(detail) }
|
||||
}
|
||||
|
||||
/** Delete the event being edited, as far as [scope] reaches; result lands in [saveState]. */
|
||||
fun delete(scope: RecurringWriteScope) {
|
||||
val target = _editTarget.value ?: return
|
||||
if (_saveState.value == SaveUiState.Saving) return
|
||||
viewModelScope.launch {
|
||||
_saveState.value = SaveUiState.Saving
|
||||
_saveState.value = try {
|
||||
repository.deleteEvent(target.eventId, target.beginMillis, scope)
|
||||
SaveUiState.Deleted
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (e: SecurityException) {
|
||||
SaveUiState.NeedsPermission
|
||||
} catch (e: NoSuchEventException) {
|
||||
SaveUiState.Gone
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "Delete failed (scope=$scope, eventId=${target.eventId})", e)
|
||||
SaveUiState.DeleteFailed
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Reset [saveState] after the screen handled a terminal result. */
|
||||
fun consumeSaveResult() {
|
||||
_saveState.value = SaveUiState.Idle
|
||||
}
|
||||
|
||||
private fun moveStart(transform: (LocalDateTime) -> LocalDateTime) = update { form ->
|
||||
private fun moveStart(transform: (LocalDateTime) -> LocalDateTime) = edit { form ->
|
||||
val zone = TimeZone.currentSystemDefault()
|
||||
val newStart = transform(form.start)
|
||||
val duration = form.end.toInstant(zone) - form.start.toInstant(zone)
|
||||
@@ -826,4 +890,12 @@ class EventEditViewModel @Inject constructor(
|
||||
private inline fun update(block: (EventForm) -> EventForm) {
|
||||
_form.value = _form.value?.let(block)
|
||||
}
|
||||
|
||||
/** A change the user made, as opposed to a default the form applied itself. */
|
||||
private inline fun edit(block: (EventForm) -> EventForm) {
|
||||
val current = _form.value ?: return
|
||||
val next = block(current)
|
||||
if (next != current) _edited.value = true
|
||||
_form.value = next
|
||||
}
|
||||
}
|
||||
|
||||
@@ -47,6 +47,8 @@ data class SettingsUiState(
|
||||
val todayButtonInToolbar: Boolean = false,
|
||||
/** Whether events can be dragged to another slot to reschedule them (#68, #173). */
|
||||
val dragToReschedule: Boolean = true,
|
||||
/** Whether tapping an event opens the edit form instead of the detail screen (#178). */
|
||||
val tapOpensEditor: Boolean = false,
|
||||
/** How far ahead the in-app Agenda screen shows events (v2.11). */
|
||||
val agendaScreenRange: AgendaRange = AgendaRange.Month,
|
||||
/** How far ahead the agenda widget shows events (v2.11). */
|
||||
|
||||
@@ -148,13 +148,16 @@ class SettingsViewModel @Inject constructor(
|
||||
prefs.softenCalendarColors,
|
||||
combine(
|
||||
prefs.todayButtonInToolbar,
|
||||
prefs.dragToReschedule,
|
||||
combine(prefs.dragToReschedule, prefs.tapOpensEditor, ::Pair),
|
||||
prefs.multiDayCount,
|
||||
::Triple,
|
||||
),
|
||||
) { hourLines, weekNumbers, showToday, soften, (todayInToolbar, dragToMove, multiDay) ->
|
||||
) { hourLines, weekNumbers, showToday, soften, (todayInToolbar, gestures, multiDay) ->
|
||||
DisplayToggles(
|
||||
hourLines, weekNumbers, showToday, soften, todayInToolbar, dragToMove, multiDay,
|
||||
hourLines, weekNumbers, showToday, soften, todayInToolbar,
|
||||
dragToReschedule = gestures.first,
|
||||
tapOpensEditor = gestures.second,
|
||||
multiDayCount = multiDay,
|
||||
)
|
||||
},
|
||||
) { view, screenRange, widgetRange, timeFormat, toggles ->
|
||||
@@ -166,6 +169,7 @@ class SettingsViewModel @Inject constructor(
|
||||
softenColors = toggles.softenColors,
|
||||
todayButtonInToolbar = toggles.todayButtonInToolbar,
|
||||
dragToReschedule = toggles.dragToReschedule,
|
||||
tapOpensEditor = toggles.tapOpensEditor,
|
||||
multiDayCount = toggles.multiDayCount,
|
||||
)
|
||||
},
|
||||
@@ -202,6 +206,7 @@ class SettingsViewModel @Inject constructor(
|
||||
softenColors = views.softenColors,
|
||||
todayButtonInToolbar = views.todayButtonInToolbar,
|
||||
dragToReschedule = views.dragToReschedule,
|
||||
tapOpensEditor = views.tapOpensEditor,
|
||||
multiDayCount = views.multiDayCount,
|
||||
agendaShowRangeBar = misc.showRangeBar,
|
||||
autofocusEventTitle = misc.autofocusEventTitle,
|
||||
@@ -305,6 +310,7 @@ class SettingsViewModel @Inject constructor(
|
||||
val softenColors: Boolean,
|
||||
val todayButtonInToolbar: Boolean,
|
||||
val dragToReschedule: Boolean,
|
||||
val tapOpensEditor: Boolean,
|
||||
val multiDayCount: Int,
|
||||
)
|
||||
|
||||
@@ -315,6 +321,7 @@ class SettingsViewModel @Inject constructor(
|
||||
val softenColors: Boolean,
|
||||
val todayButtonInToolbar: Boolean,
|
||||
val dragToReschedule: Boolean,
|
||||
val tapOpensEditor: Boolean,
|
||||
val multiDayCount: Int,
|
||||
)
|
||||
|
||||
@@ -562,6 +569,10 @@ class SettingsViewModel @Inject constructor(
|
||||
viewModelScope.launch { prefs.setDragToReschedule(enabled) }
|
||||
}
|
||||
|
||||
fun setTapOpensEditor(enabled: Boolean) {
|
||||
viewModelScope.launch { prefs.setTapOpensEditor(enabled) }
|
||||
}
|
||||
|
||||
/**
|
||||
* Switch the launcher label (#44). The card highlights at once, then settles
|
||||
* on what the component state reports; the writes are binder round-trips and
|
||||
|
||||
@@ -74,6 +74,7 @@ internal fun ViewsScreen(
|
||||
var showDefaultView by remember { mutableStateOf(false) }
|
||||
var showWeekStart by remember { mutableStateOf(false) }
|
||||
var showTimeFormat by remember { mutableStateOf(false) }
|
||||
var showTapOpens by remember { mutableStateOf(false) }
|
||||
var showPastEvents by remember { mutableStateOf(false) }
|
||||
var showAgendaScreenRange by remember { mutableStateOf(false) }
|
||||
var showTimelineScale by remember { mutableStateOf(false) }
|
||||
@@ -125,7 +126,7 @@ internal fun ViewsScreen(
|
||||
GroupedRow(
|
||||
title = stringResource(R.string.settings_dim_completed),
|
||||
summary = stringResource(R.string.settings_dim_completed_summary),
|
||||
position = Position.Middle,
|
||||
position = Position.Bottom,
|
||||
trailing = {
|
||||
Switch(
|
||||
checked = state.dimCompletedEvents,
|
||||
@@ -134,6 +135,17 @@ internal fun ViewsScreen(
|
||||
},
|
||||
onClick = { viewModel.setDimCompletedEvents(!state.dimCompletedEvents) },
|
||||
)
|
||||
|
||||
Spacer(Modifier.height(8.dp))
|
||||
|
||||
// How events respond to a tap and a long press (#178, #141).
|
||||
SectionHeader(stringResource(R.string.settings_events_header))
|
||||
GroupedRow(
|
||||
title = stringResource(R.string.settings_tap_opens),
|
||||
summary = tapOpensLabel(state.tapOpensEditor),
|
||||
position = Position.Top,
|
||||
onClick = { showTapOpens = true },
|
||||
)
|
||||
GroupedRow(
|
||||
title = stringResource(R.string.settings_drag_to_reschedule),
|
||||
summary = stringResource(R.string.settings_drag_to_reschedule_summary),
|
||||
@@ -387,6 +399,18 @@ internal fun ViewsScreen(
|
||||
onDismiss = { showTimeFormat = false },
|
||||
)
|
||||
}
|
||||
if (showTapOpens) {
|
||||
OptionPicker(
|
||||
title = stringResource(R.string.settings_tap_opens),
|
||||
header = { PickerDescription(stringResource(R.string.settings_tap_opens_hint)) },
|
||||
predictiveBack = true,
|
||||
options = listOf(false, true),
|
||||
selected = state.tapOpensEditor,
|
||||
label = { tapOpensLabel(it) },
|
||||
onSelect = viewModel::setTapOpensEditor,
|
||||
onDismiss = { showTapOpens = false },
|
||||
)
|
||||
}
|
||||
if (showPastEvents) {
|
||||
PastEventsPicker(
|
||||
selected = state.pastEventDisplay,
|
||||
@@ -522,6 +546,11 @@ internal fun weekStartLabel(pref: WeekStartPref): String = when (pref) {
|
||||
.getDisplayName(JavaTextStyle.FULL, currentLocale())
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun tapOpensLabel(editor: Boolean): String = stringResource(
|
||||
if (editor) R.string.settings_tap_opens_editor else R.string.settings_tap_opens_details,
|
||||
)
|
||||
|
||||
@Composable
|
||||
private fun timeFormatLabel(pref: TimeFormatPref): String = stringResource(
|
||||
when (pref) {
|
||||
|
||||
@@ -118,6 +118,11 @@
|
||||
<!-- Event form (v1.2 create) -->
|
||||
<string name="event_edit_new_title">New event</string>
|
||||
<string name="event_edit_close">Close</string>
|
||||
<string name="event_edit_more_actions">More actions</string>
|
||||
<string name="event_edit_unsaved_title">Unsaved changes</string>
|
||||
<string name="event_edit_unsaved_save">Save changes</string>
|
||||
<string name="event_edit_unsaved_discard">Discard changes</string>
|
||||
<string name="event_edit_unsaved_keep">Keep editing</string>
|
||||
<string name="event_edit_save">Save</string>
|
||||
<string name="event_edit_title_hint">Add title</string>
|
||||
<!-- %1$s is the managing calendar's name, e.g. "Birthdays". -->
|
||||
@@ -518,6 +523,11 @@
|
||||
<string name="settings_dim_completed_summary">Fade events that have already ended in month and week view</string>
|
||||
<string name="settings_drag_to_reschedule">Drag to reschedule</string>
|
||||
<string name="settings_drag_to_reschedule_summary">Move an event by dragging it to another day or time</string>
|
||||
<string name="settings_events_header">Events</string>
|
||||
<string name="settings_tap_opens">Tapping an event opens</string>
|
||||
<string name="settings_tap_opens_details">Details</string>
|
||||
<string name="settings_tap_opens_editor">Edit form</string>
|
||||
<string name="settings_tap_opens_hint">Events you can\'t edit and reminder notifications always open the details.</string>
|
||||
<string name="settings_past_events">Past events</string>
|
||||
<string name="settings_past_events_show">Show</string>
|
||||
<string name="settings_past_events_dim">Dim</string>
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
package de.jeanlucmakiola.calendula.ui.edit
|
||||
|
||||
import android.content.ContextWrapper
|
||||
import androidx.datastore.core.DataStore
|
||||
import androidx.datastore.preferences.core.PreferenceDataStoreFactory
|
||||
import androidx.datastore.preferences.core.Preferences
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import de.jeanlucmakiola.calendula.data.calendar.CalendarRepositoryImpl
|
||||
import de.jeanlucmakiola.calendula.data.calendar.FakeCalendarDataSource
|
||||
import de.jeanlucmakiola.calendula.data.ics.IcsExporter
|
||||
import de.jeanlucmakiola.calendula.data.prefs.CalendarPrefs
|
||||
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
|
||||
import de.jeanlucmakiola.calendula.domain.CalendarSource
|
||||
@@ -23,6 +25,7 @@ import kotlinx.coroutines.test.resetMain
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import kotlinx.coroutines.test.setMain
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import de.jeanlucmakiola.calendula.domain.RecurringWriteScope
|
||||
import kotlinx.datetime.LocalDate
|
||||
import kotlinx.datetime.LocalDateTime
|
||||
import kotlinx.datetime.LocalTime
|
||||
@@ -95,7 +98,8 @@ class EventEditViewModelTest {
|
||||
): EventEditViewModel {
|
||||
val p = prefs(tempDir)
|
||||
val repo = CalendarRepositoryImpl(fake, p, s, dispatcher as CoroutineDispatcher)
|
||||
return EventEditViewModel(repo, p, s, dispatcher)
|
||||
// Only `shareUri()` touches the exporter, and nothing here shares.
|
||||
return EventEditViewModel(repo, p, s, IcsExporter(ContextWrapper(null)), dispatcher)
|
||||
}
|
||||
|
||||
/** Keep [EventEditViewModel.state] hot so it computes while the test drives it. */
|
||||
@@ -407,4 +411,68 @@ class EventEditViewModelTest {
|
||||
|
||||
assertThat(capLength(new, "")).isEqualTo("a".repeat(254))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an edit counts as unsaved only while it differs from the loaded event`(
|
||||
@TempDir tempDir: Path,
|
||||
) = runTest(dispatcher) {
|
||||
val fake = FakeCalendarDataSource().apply {
|
||||
calendarsResult = listOf(cal(1L))
|
||||
eventDetailResult = { detail(calendarId = 1L) }
|
||||
}
|
||||
val vm = viewModel(tempDir, fake)
|
||||
val job = activate(vm)
|
||||
|
||||
vm.openForEdit(eventId = 42L, beginMillis = beginMillis, endMillis = endMillis)
|
||||
advanceUntilIdle()
|
||||
assertThat(vm.hasUnsavedChanges.value).isFalse()
|
||||
|
||||
vm.setTitle("Retro")
|
||||
advanceUntilIdle()
|
||||
assertThat(vm.hasUnsavedChanges.value).isTrue()
|
||||
|
||||
vm.setTitle("Standup")
|
||||
advanceUntilIdle()
|
||||
assertThat(vm.hasUnsavedChanges.value).isFalse()
|
||||
job.cancel()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a new form holding only its defaults has nothing to lose`(
|
||||
@TempDir tempDir: Path,
|
||||
) = runTest(dispatcher) {
|
||||
val fake = FakeCalendarDataSource().apply { calendarsResult = listOf(cal(1L)) }
|
||||
val vm = viewModel(tempDir, fake)
|
||||
val job = activate(vm)
|
||||
|
||||
vm.openNew(LocalDate(2026, 10, 12), startMinutes = 9 * 60)
|
||||
advanceUntilIdle()
|
||||
assertThat(vm.hasUnsavedChanges.value).isFalse()
|
||||
|
||||
vm.setTitle("Lunch")
|
||||
advanceUntilIdle()
|
||||
assertThat(vm.hasUnsavedChanges.value).isTrue()
|
||||
job.cancel()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `deleting from the form removes the event and reports it`(
|
||||
@TempDir tempDir: Path,
|
||||
) = runTest(dispatcher) {
|
||||
val fake = FakeCalendarDataSource().apply {
|
||||
calendarsResult = listOf(cal(1L))
|
||||
eventDetailResult = { detail(calendarId = 1L) }
|
||||
}
|
||||
val vm = viewModel(tempDir, fake)
|
||||
val job = activate(vm)
|
||||
|
||||
vm.openForEdit(eventId = 42L, beginMillis = beginMillis, endMillis = endMillis)
|
||||
advanceUntilIdle()
|
||||
vm.delete(RecurringWriteScope.AllEvents)
|
||||
advanceUntilIdle()
|
||||
|
||||
assertThat(fake.deletedEventIds).containsExactly(42L)
|
||||
assertThat(vm.state.value?.saveState).isEqualTo(SaveUiState.Deleted)
|
||||
job.cancel()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -119,7 +119,11 @@ gesture finishes from its scaled preview.
|
||||
switch only changes what they say.
|
||||
A tapped reminder notification routes through `MainActivity` (`singleTop` +
|
||||
`onNewIntent`) as an external detail key that `CalendarHost` consumes
|
||||
exactly like an event tap.
|
||||
like an event tap. With the "tapping an event opens" setting on the edit form
|
||||
(#178), a tap in the views or search opens the edit overlay directly for events
|
||||
in writable calendars; notifications and widgets always open the detail. The
|
||||
edit form guards its own back: unsaved changes ask to save, discard or keep
|
||||
editing.
|
||||
|
||||
### First-launch wizard
|
||||
|
||||
|
||||
Reference in New Issue
Block a user