From be1501f6e13cecbddc789978816cbdfdec34eef3 Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Mon, 12 Oct 2026 23:44:09 +0200 Subject: [PATCH] Tapping an event can open the edit form (#178) --- .../calendula/data/prefs/SettingsPrefs.kt | 14 ++ .../calendula/ui/CalendarHost.kt | 17 +- .../calendula/ui/CalendarHostViewModel.kt | 8 + .../calendula/ui/edit/EventEditScreen.kt | 172 +++++++++++++++++- .../calendula/ui/edit/EventEditUiState.kt | 3 + .../calendula/ui/edit/EventEditViewModel.kt | 114 +++++++++--- .../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 | 70 ++++++- docs/ARCHITECTURE.md | 6 +- 12 files changed, 434 insertions(+), 30 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 bc098c9e..25ced1ce 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 704481f2..500d8c00 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/CalendarHost.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/CalendarHost.kt @@ -192,7 +192,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 +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, ) } 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 789e9a71..d3f64776 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/edit/EventEditScreen.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/edit/EventEditScreen.kt index 2fde7aa3..68ddfab3 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,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 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 6a68d045..13117970 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,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 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 d758baf0..2a27516f 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,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(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 +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 = 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 + } } 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 16111c17..0af6fd7a 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 75ebca18..5bee5a83 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 22ccb1ae..1f8bf67e 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 259e1e14..dd8bbd59 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 8ee8926f..6abda41e 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,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() + } } diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index f5d31dcd..77fc57c4 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