From 951ad9cd6d2523ad2f6d454a0d073b610d91e431 Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Wed, 14 Oct 2026 14:33:38 +0200 Subject: [PATCH] Tapping an event can open the edit form (#178) (#394) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New setting under Settings → Views → Events: "Tapping an event opens" Details (default) or Edit form. The long-press toggle moves into the same group. - Taps in the views and in search open the editor directly for events in writable calendars. Read-only events, reminder notifications and widgets always open the details. - Installs without write access are asked for it on the tap; refusing opens the details instead. - The editor gets a back guard: with unsaved changes it asks to save, discard or keep editing; otherwise it closes silently. - The editor gets Duplicate, Share and Delete in an overflow menu, so skipping the details doesn't lose them. - If the event can't be loaded, the details screen opens and shows why. - Each editor overlay (create, edit, duplicate/import) now has its own ViewModel. Duplicating from an edited form previously reused the edit form's state. - Deletes from the details screen, the editor and the selection bar share one error mapping. Deleting an event that's already gone counts as deleted in the details screen and the editor. Deviation from the issue: it asked for back to save automatically. Following the discussion in the comments, auto-save isn't in this PR. Back asks only when something actually changed, and Details stays the default. Closes #178 Co-authored-by: Jean-Luc Makiola Reviewed-on: https://codeberg.org/jlmakiola/calendula/pulls/394 --- .../calendula/data/prefs/SettingsPrefs.kt | 14 ++ .../calendula/ui/CalendarHost.kt | 31 ++- .../calendula/ui/CalendarHostViewModel.kt | 8 + .../calendula/ui/common/EventActions.kt | 40 +++- .../ui/common/EventActionsViewModel.kt | 16 +- .../ui/detail/EventDetailViewModel.kt | 17 +- .../calendula/ui/edit/EventEditScreen.kt | 189 +++++++++++++++++- .../calendula/ui/edit/EventEditUiState.kt | 5 + .../calendula/ui/edit/EventEditViewModel.kt | 110 ++++++++-- .../calendula/ui/settings/SettingsUiState.kt | 2 + .../ui/settings/SettingsViewModel.kt | 17 +- .../calendula/ui/settings/ViewsSettings.kt | 31 ++- app/src/main/res/values/strings.xml | 10 + .../ui/edit/EventEditViewModelTest.kt | 152 +++++++++++++- docs/ARCHITECTURE.md | 6 +- 15 files changed, 589 insertions(+), 59 deletions(-) diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/data/prefs/SettingsPrefs.kt b/app/src/main/java/de/jeanlucmakiola/calendula/data/prefs/SettingsPrefs.kt index bc098c9..25ced1c 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/data/prefs/SettingsPrefs.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/data/prefs/SettingsPrefs.kt @@ -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 = 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") diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/CalendarHost.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/CalendarHost.kt index 704481f..e3eae60 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/CalendarHost.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/CalendarHost.kt @@ -60,6 +60,7 @@ import de.jeanlucmakiola.calendula.ui.common.EventMoveHost import de.jeanlucmakiola.calendula.ui.common.EventSelectionHost import de.jeanlucmakiola.calendula.ui.common.eventActionFailureMessage import de.jeanlucmakiola.calendula.ui.common.rememberFabSelection +import de.jeanlucmakiola.calendula.ui.common.rememberWriteGateOr import de.jeanlucmakiola.calendula.ui.common.EventMoveScope import de.jeanlucmakiola.calendula.ui.common.LocalEventMove import de.jeanlucmakiola.calendula.ui.common.LocalManageCalendars @@ -192,7 +193,7 @@ fun CalendarHost( // placeholder — so the destination never loads a bogus id=0 on first frame. var detailKey by rememberSaveable { mutableStateOf(null) } var heldKey by remember { mutableStateOf(null) } - val onEventClick: (EventInstance) -> Unit = { event -> + val onOpenDetail: (EventInstance) -> Unit = { event -> val key = longArrayOf( event.eventId, event.start.toEpochMilliseconds(), @@ -410,6 +411,18 @@ 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 requireWriteOr = rememberWriteGateOr() + val onEventClick: (EventInstance) -> Unit = { event -> + if (tapOpensEditor && actionCalendars[event.calendarId]?.canModifyContents == true) { + requireWriteOr({ onEditEvent(event) }, { onOpenDetail(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 @@ -685,6 +698,7 @@ fun CalendarHost( onSaved = { createDateIso = null }, onManageCalendars = { showCalendars = true }, visible = createDateIso != null, + viewModel = hiltViewModel(key = "create"), ) } } @@ -705,7 +719,21 @@ fun CalendarHost( detailKey = null }, onManageCalendars = { showCalendars = true }, + onDuplicate = { form -> + importFormSource = ImportSource.Insert + importForm = form + }, + // Opened straight from a tap (#178) there's no detail screen + // below to show why, so open it. + onLoadFailed = { + editKey = null + if (detailKey == null) { + heldKey = key + detailKey = key + } + }, visible = editKey != null, + viewModel = hiltViewModel(key = "edit"), ) } } @@ -764,6 +792,7 @@ fun CalendarHost( onClose = { importForm = null }, onSaved = { importForm = null }, onManageCalendars = { showCalendars = true }, + viewModel = hiltViewModel(key = "import"), ) } diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/CalendarHostViewModel.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/CalendarHostViewModel.kt index 789e9a7..d3f6477 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/CalendarHostViewModel.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/CalendarHostViewModel.kt @@ -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 = prefs.tapOpensEditor + .stateIn( + scope = viewModelScope, + started = SharingStarted.WhileSubscribed(5_000L), + initialValue = false, + ) } diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/EventActions.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/EventActions.kt index 500cade..319c65b 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/EventActions.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/EventActions.kt @@ -5,6 +5,7 @@ import android.content.Context import android.content.Intent import android.content.pm.PackageManager import android.net.Uri +import android.util.Log import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.foundation.layout.Arrangement @@ -25,12 +26,14 @@ import androidx.compose.ui.unit.dp import androidx.core.content.ContextCompat import de.jeanlucmakiola.calendula.R import de.jeanlucmakiola.calendula.data.calendar.CalendarRepository +import de.jeanlucmakiola.calendula.data.calendar.NoSuchEventException import de.jeanlucmakiola.calendula.data.ics.IcsExporter import de.jeanlucmakiola.calendula.domain.EventDetail import de.jeanlucmakiola.calendula.domain.RecurringWriteScope import de.jeanlucmakiola.calendula.domain.ics.IcsWriter import de.jeanlucmakiola.calendula.domain.ics.toShareIcsEvent import de.jeanlucmakiola.floret.components.OptionCard +import kotlin.coroutines.cancellation.CancellationException import kotlin.time.Clock /** @@ -67,6 +70,28 @@ suspend fun CalendarRepository.deleteEvent( RecurringWriteScope.ThisAndFollowing -> deleteEventFromOccurrence(eventId, beginMillis) } +/** How a [tryDeleteEvent] ended. */ +enum class DeleteOutcome { Deleted, Gone, NeedsPermission, Failed } + +/** [deleteEvent], with the provider's failures mapped to a [DeleteOutcome]. */ +suspend fun CalendarRepository.tryDeleteEvent( + eventId: Long, + beginMillis: Long, + scope: RecurringWriteScope, +): DeleteOutcome = try { + deleteEvent(eventId, beginMillis, scope) + DeleteOutcome.Deleted +} catch (e: CancellationException) { + throw e +} catch (e: SecurityException) { + DeleteOutcome.NeedsPermission +} catch (e: NoSuchEventException) { + DeleteOutcome.Gone +} catch (e: Exception) { + Log.w("EventActions", "Delete failed (scope=$scope, eventId=$eventId)", e) + DeleteOutcome.Failed +} + /** * Runs an action that writes to the calendar, asking for `WRITE_CALENDAR` first * where the install only holds read access (v1.0 installs). A grant continues @@ -74,15 +99,22 @@ suspend fun CalendarRepository.deleteEvent( */ @Composable fun rememberWriteGate(): (() -> Unit) -> Unit { + val gate = rememberWriteGateOr() + return { action -> gate(action) {} } +} + +/** [rememberWriteGate], running the fallback instead when the user refuses. */ +@Composable +fun rememberWriteGateOr(): (action: () -> Unit, fallback: () -> Unit) -> Unit { val context = LocalContext.current - var pending by remember { mutableStateOf<(() -> Unit)?>(null) } + var pending by remember { mutableStateOf Unit, () -> Unit>?>(null) } val launcher = rememberLauncherForActivityResult( contract = ActivityResultContracts.RequestPermission(), ) { granted -> - if (granted) pending?.invoke() + pending?.let { (action, fallback) -> if (granted) action() else fallback() } pending = null } - return { action -> + return { action, fallback -> val granted = ContextCompat.checkSelfPermission( context, Manifest.permission.WRITE_CALENDAR, @@ -90,7 +122,7 @@ fun rememberWriteGate(): (() -> Unit) -> Unit { if (granted) { action() } else { - pending = action + pending = action to fallback launcher.launch(Manifest.permission.WRITE_CALENDAR) } } diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/EventActionsViewModel.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/EventActionsViewModel.kt index f05a5e7..8837a3d 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/EventActionsViewModel.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/EventActionsViewModel.kt @@ -87,17 +87,11 @@ class EventActionsViewModel @Inject constructor( _deletePrompt.value = null viewModelScope.launch { val event = prompt.event - _failure.value = try { - repository.deleteEvent(event.eventId, event.start.toEpochMilliseconds(), scope) - null - } catch (e: CancellationException) { - throw e - } catch (e: SecurityException) { - EventActionFailure.WriteDenied - } catch (e: NoSuchEventException) { - EventActionFailure.Gone - } catch (e: Exception) { - EventActionFailure.DeleteFailed + _failure.value = when (repository.tryDeleteEvent(event.eventId, event.start.toEpochMilliseconds(), scope)) { + DeleteOutcome.Deleted -> null + DeleteOutcome.Gone -> EventActionFailure.Gone + DeleteOutcome.NeedsPermission -> EventActionFailure.WriteDenied + DeleteOutcome.Failed -> EventActionFailure.DeleteFailed } } } diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/detail/EventDetailViewModel.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/detail/EventDetailViewModel.kt index 9f60ab7..76da166 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/detail/EventDetailViewModel.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/detail/EventDetailViewModel.kt @@ -12,7 +12,8 @@ import de.jeanlucmakiola.calendula.domain.EventForm import de.jeanlucmakiola.calendula.domain.FailureReason import de.jeanlucmakiola.calendula.domain.RecurringWriteScope import de.jeanlucmakiola.calendula.domain.toDuplicateForm -import de.jeanlucmakiola.calendula.ui.common.deleteEvent +import de.jeanlucmakiola.calendula.ui.common.DeleteOutcome +import de.jeanlucmakiola.calendula.ui.common.tryDeleteEvent import de.jeanlucmakiola.calendula.ui.common.stageEventShare import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.withContext @@ -111,15 +112,11 @@ class EventDetailViewModel @Inject constructor( if (_deleteState.value == DeleteUiState.Deleting) return viewModelScope.launch { _deleteState.value = DeleteUiState.Deleting - _deleteState.value = try { - repository.deleteEvent(target.eventId, target.beginMillis, scope) - DeleteUiState.Deleted - } catch (e: CancellationException) { - throw e - } catch (e: SecurityException) { - DeleteUiState.NeedsPermission - } catch (e: Exception) { - DeleteUiState.Failed + // Already gone is what a delete wants anyway. + _deleteState.value = when (repository.tryDeleteEvent(target.eventId, target.beginMillis, scope)) { + DeleteOutcome.Deleted, DeleteOutcome.Gone -> DeleteUiState.Deleted + DeleteOutcome.NeedsPermission -> DeleteUiState.NeedsPermission + DeleteOutcome.Failed -> DeleteUiState.Failed } } } diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/edit/EventEditScreen.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/edit/EventEditScreen.kt index 2fde7aa..42822c2 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/edit/EventEditScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/edit/EventEditScreen.kt @@ -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,9 @@ fun EventEditScreen( initialForm: EventForm? = null, initialFormSource: ImportSource = ImportSource.File, onManageCalendars: (() -> Unit)? = null, + onDuplicate: ((EventForm) -> Unit)? = null, + onDeleted: () -> Unit = onSaved, + onLoadFailed: () -> Unit = onClose, visible: Boolean = true, viewModel: EventEditViewModel = hiltViewModel(), ) { @@ -236,10 +252,19 @@ fun EventEditScreen( viewModel.reset() onClose() } - // The event vanished between the detail screen and the edit tap — fall - // back to the detail screen, which shows its own failure state. + 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 before the form could load it — fall back to the + // detail screen, which shows its own failure state. LaunchedEffect(loadFailed) { - if (loadFailed) close() + if (loadFailed) { + viewModel.reset() + onLoadFailed() + } } val context = LocalContext.current @@ -263,14 +288,49 @@ 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 = { + requireWrite { + 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) + val deleteDeniedMessage = stringResource(R.string.event_delete_write_denied) 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) + } + SaveUiState.DeleteDenied -> { + viewModel.consumeSaveResult() + snackbarHostState.showSnackbar(deleteDeniedMessage, 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 +348,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 +356,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 +364,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 +399,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 +469,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 diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/edit/EventEditUiState.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/edit/EventEditUiState.kt index 6a68d04..1be5d3b 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/edit/EventEditUiState.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/edit/EventEditUiState.kt @@ -77,6 +77,11 @@ 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 before a delete from the form. */ + data object DeleteDenied : SaveUiState /** WRITE_CALENDAR was revoked between the tap and the provider call. */ data object NeedsPermission : SaveUiState data object Failed : SaveUiState diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/edit/EventEditViewModel.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/edit/EventEditViewModel.kt index d758baf..7d90f47 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/edit/EventEditViewModel.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/edit/EventEditViewModel.kt @@ -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,9 @@ 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.DeleteOutcome +import de.jeanlucmakiola.calendula.ui.common.tryDeleteEvent +import de.jeanlucmakiola.calendula.ui.common.stageEventShare import de.jeanlucmakiola.calendula.ui.common.wallClockTime import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.ExperimentalCoroutinesApi @@ -133,6 +139,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 +164,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(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 = _loadFailed.asStateFlow() @@ -177,6 +187,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 +324,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 = 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 @@ -496,7 +520,7 @@ class EventEditViewModel @Inject constructor( 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) } + edit { it.copy(reminders = prompt.defaultReminders) } _revealed.value = _revealed.value + EventFormField.Reminders } @@ -531,7 +555,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 +579,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 +590,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 +622,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 +634,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 +673,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 +681,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 +700,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 +841,39 @@ class EventEditViewModel @Inject constructor( } } + /** Whether the event being edited recurs; null while not editing. */ + fun editedEventRecurs(): Boolean? = _editTarget.value?.let { !it.detail.rrule.isNullOrBlank() } + + /** 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 + // Already gone is what a delete wants anyway. + _saveState.value = when (repository.tryDeleteEvent(target.eventId, target.beginMillis, scope)) { + DeleteOutcome.Deleted, DeleteOutcome.Gone -> SaveUiState.Deleted + DeleteOutcome.NeedsPermission -> SaveUiState.DeleteDenied + DeleteOutcome.Failed -> 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 +884,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 + } } diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/SettingsUiState.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/SettingsUiState.kt index 16111c1..0af6fd7 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/SettingsUiState.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/SettingsUiState.kt @@ -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). */ diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/SettingsViewModel.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/SettingsViewModel.kt index 75ebca1..5bee5a8 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/SettingsViewModel.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/SettingsViewModel.kt @@ -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 diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/ViewsSettings.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/ViewsSettings.kt index 22ccb1a..1f8bf67 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/ViewsSettings.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/ViewsSettings.kt @@ -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) { diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 259e1e1..dd8bbd5 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -118,6 +118,11 @@ New event Close + More actions + Unsaved changes + Save changes + Discard changes + Keep editing Save Add title @@ -518,6 +523,11 @@ Fade events that have already ended in month and week view Drag to reschedule Move an event by dragging it to another day or time + Events + Tapping an event opens + Details + Edit form + Events you can\'t edit and reminder notifications always open the details. Past events Show Dim diff --git a/app/src/test/java/de/jeanlucmakiola/calendula/ui/edit/EventEditViewModelTest.kt b/app/src/test/java/de/jeanlucmakiola/calendula/ui/edit/EventEditViewModelTest.kt index 8ee8926..e21e756 100644 --- a/app/src/test/java/de/jeanlucmakiola/calendula/ui/edit/EventEditViewModelTest.kt +++ b/app/src/test/java/de/jeanlucmakiola/calendula/ui/edit/EventEditViewModelTest.kt @@ -1,11 +1,14 @@ 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.calendar.NoSuchEventException +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 +26,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 +99,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 +412,149 @@ 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() + } + + @Test + fun `deleting an event that is already gone counts as deleted`( + @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() + fake.writeError = NoSuchEventException(42L) + vm.delete(RecurringWriteScope.AllEvents) + advanceUntilIdle() + + assertThat(vm.state.value?.saveState).isEqualTo(SaveUiState.Deleted) + job.cancel() + } + + @Test + fun `a delete without write access reports it as a delete`( + @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() + fake.writeError = SecurityException() + vm.delete(RecurringWriteScope.AllEvents) + advanceUntilIdle() + + assertThat(vm.state.value?.saveState).isEqualTo(SaveUiState.DeleteDenied) + job.cancel() + } + + @Test + fun `a blank rrule does not make the edited event recurring`( + @TempDir tempDir: Path, + ) = runTest(dispatcher) { + val fake = FakeCalendarDataSource().apply { + calendarsResult = listOf(cal(1L)) + eventDetailResult = { detail(calendarId = 1L, rrule = "") } + } + val vm = viewModel(tempDir, fake) + val job = activate(vm) + + vm.openForEdit(eventId = 42L, beginMillis = beginMillis, endMillis = endMillis) + advanceUntilIdle() + + assertThat(vm.editedEventRecurs()).isFalse() + job.cancel() + } + + @Test + fun `accepting the default reminders for an import counts as a change`( + @TempDir tempDir: Path, + ) = runTest(dispatcher) { + val fake = FakeCalendarDataSource().apply { calendarsResult = listOf(cal(1L)) } + val s = settings(tempDir) + s.setDefaultReminderMinutes(listOf(15)) + val vm = viewModel(tempDir, fake, s) + val job = activate(vm) + + vm.openImported(importedForm(end = LocalDateTime(2030, 1, 15, 10, 0)), ImportSource.File) + advanceUntilIdle() + assertThat(vm.importReminderPrompt.value).isNotNull() + assertThat(vm.hasUnsavedChanges.value).isFalse() + + vm.applyImportedReminderDefault() + advanceUntilIdle() + assertThat(vm.hasUnsavedChanges.value).isTrue() + job.cancel() + } } diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index f5d31dc..77fc57c 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -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