From f1f2a2c9c2de7831c961551b4faf98f45069ccee Mon Sep 17 00:00:00 2001 From: Patrick Lang <72232737+patrickunterwegs@users.noreply.github.com> Date: Tue, 29 Sep 2026 12:35:39 +0300 Subject: [PATCH 1/3] implement basic undo functionality --- .../composeResources/values/strings.xml | 2 +- .../details/presentation/DetailsAction.kt | 1 + .../details/presentation/DetailsScreen.kt | 67 +---------- .../details/presentation/DetailsState.kt | 2 + .../details/presentation/DetailsViewModel.kt | 21 ++++ .../presentation/components/FormattingBar.kt | 113 ++++++++++++++++++ 6 files changed, 141 insertions(+), 65 deletions(-) create mode 100644 shared/src/commonMain/kotlin/at/techbee/spectacled/screens/details/presentation/components/FormattingBar.kt diff --git a/shared/src/commonMain/composeResources/values/strings.xml b/shared/src/commonMain/composeResources/values/strings.xml index d8fbcda8..c485118f 100644 --- a/shared/src/commonMain/composeResources/values/strings.xml +++ b/shared/src/commonMain/composeResources/values/strings.xml @@ -85,7 +85,6 @@ Select all Select multiple Show error details - Undo Last %1$d hours Last %1$d days @@ -231,6 +230,7 @@ Bold Italic Underline + Undo Theme Default (System) diff --git a/shared/src/commonMain/kotlin/at/techbee/spectacled/screens/details/presentation/DetailsAction.kt b/shared/src/commonMain/kotlin/at/techbee/spectacled/screens/details/presentation/DetailsAction.kt index eee1643c..29b6dc5f 100644 --- a/shared/src/commonMain/kotlin/at/techbee/spectacled/screens/details/presentation/DetailsAction.kt +++ b/shared/src/commonMain/kotlin/at/techbee/spectacled/screens/details/presentation/DetailsAction.kt @@ -26,6 +26,7 @@ sealed interface DetailsAction { data class OnShowDeleteAttachmentDialog(val attachmentUid: String?): DetailsAction data class OnDeleteAttachment(val attachmentUid: String): DetailsAction data class OnUpdateDrawing(val replaceAttachmentUid: String?, val paths: List, val width: Float, val height: Float): DetailsAction + object OnUndo: DetailsAction data class OnNewCalendarIdSelected(val calendarId: Long): DetailsAction diff --git a/shared/src/commonMain/kotlin/at/techbee/spectacled/screens/details/presentation/DetailsScreen.kt b/shared/src/commonMain/kotlin/at/techbee/spectacled/screens/details/presentation/DetailsScreen.kt index 38d53f20..a0cc9d7a 100644 --- a/shared/src/commonMain/kotlin/at/techbee/spectacled/screens/details/presentation/DetailsScreen.kt +++ b/shared/src/commonMain/kotlin/at/techbee/spectacled/screens/details/presentation/DetailsScreen.kt @@ -22,12 +22,8 @@ import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.outlined.Label -import androidx.compose.material.icons.filled.FormatBold -import androidx.compose.material.icons.filled.FormatItalic -import androidx.compose.material.icons.filled.FormatUnderlined import androidx.compose.material.icons.outlined.DragIndicator import androidx.compose.material.icons.outlined.EventRepeat -import androidx.compose.material.icons.outlined.KeyboardArrowDown import androidx.compose.material.icons.outlined.RestoreFromTrash import androidx.compose.material3.DatePickerDefaults import androidx.compose.material3.Icon @@ -48,7 +44,6 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.focus.FocusRequester -import androidx.compose.ui.focus.focusProperties import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.focus.onFocusChanged import androidx.compose.ui.geometry.Offset @@ -84,12 +79,12 @@ import at.techbee.spectacled.screens.core.domain.Attachment import at.techbee.spectacled.screens.core.domain.CalendarComponent import at.techbee.spectacled.screens.core.domain.IcalEntry import at.techbee.spectacled.screens.core.domain.Status -import at.techbee.spectacled.screens.core.presentation.MarkdownFormat import at.techbee.spectacled.screens.core.presentation.MarkdownVisualTransformation import at.techbee.spectacled.screens.core.presentation.applyMarkdownFormat import at.techbee.spectacled.screens.core.presentation.components.WavyHorizontalDivider import at.techbee.spectacled.screens.details.presentation.components.AttachmentCard import at.techbee.spectacled.screens.details.presentation.components.DateTimeCard +import at.techbee.spectacled.screens.details.presentation.components.FormattingBar import at.techbee.spectacled.screens.details.presentation.components.UrlCard import at.techbee.spectacled.screens.list.presentation.components.MetaInfoCard import at.techbee.spectacled.screens.list.presentation.components.TaskListItem @@ -103,9 +98,6 @@ import spectacled.shared.generated.resources.date_due import spectacled.shared.generated.resources.date_start import spectacled.shared.generated.resources.description import spectacled.shared.generated.resources.drag_handle -import spectacled.shared.generated.resources.format_bold -import spectacled.shared.generated.resources.format_italic -import spectacled.shared.generated.resources.format_underline import spectacled.shared.generated.resources.recurring_entry_read_only_message import spectacled.shared.generated.resources.recurring_entry_read_only_title import spectacled.shared.generated.resources.summary @@ -533,7 +525,8 @@ fun DetailsScreen( onReleaseFocus = { focusManager.clearFocus() keyboardController?.hide() - } + }, + onUndo = { onAction(DetailsAction.OnUndo) } ) } } @@ -622,60 +615,6 @@ private fun Modifier.openLinkOnTap( } } -/** - * Slim formatting bar (bold / italic / underline) meant to sit just above the software keyboard. - * The buttons are made non-focusable so tapping them does not steal focus from the editor and hide - * the keyboard. - */ -@Composable -private fun FormattingBar( - onFormat: (MarkdownFormat) -> Unit, - onReleaseFocus: () -> Unit, - modifier: Modifier = Modifier -) { - Surface( - tonalElevation = 3.dp, - shadowElevation = 6.dp, - shape = RoundedCornerShape(topStart = 16.dp, topEnd = 16.dp, bottomStart = 0.dp, bottomEnd = 0.dp), - modifier = modifier.fillMaxWidth() - ) { - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(4.dp), - modifier = Modifier.padding(horizontal = 4.dp).fillMaxWidth() - ) { - IconButton( - onClick = { onFormat(MarkdownFormat.BOLD) }, - modifier = Modifier.focusProperties { canFocus = false } - ) { - Icon(Icons.Default.FormatBold, contentDescription = stringResource(Res.string.format_bold)) - } - IconButton( - onClick = { onFormat(MarkdownFormat.ITALIC) }, - modifier = Modifier.focusProperties { canFocus = false } - ) { - Icon(Icons.Default.FormatItalic, contentDescription = stringResource(Res.string.format_italic)) - } - IconButton( - onClick = { onFormat(MarkdownFormat.UNDERLINE) }, - modifier = Modifier.focusProperties { canFocus = false } - ) { - Icon(Icons.Default.FormatUnderlined, contentDescription = stringResource(Res.string.format_underline)) - } - - Spacer(modifier = Modifier.weight(1f)) - - IconButton( - onClick = { onReleaseFocus() }, - modifier = Modifier.focusProperties { canFocus = false } - ) { - Icon(Icons.Outlined.KeyboardArrowDown, null) - } - - } - } -} - @Preview @Composable diff --git a/shared/src/commonMain/kotlin/at/techbee/spectacled/screens/details/presentation/DetailsState.kt b/shared/src/commonMain/kotlin/at/techbee/spectacled/screens/details/presentation/DetailsState.kt index aa227172..db23e1df 100644 --- a/shared/src/commonMain/kotlin/at/techbee/spectacled/screens/details/presentation/DetailsState.kt +++ b/shared/src/commonMain/kotlin/at/techbee/spectacled/screens/details/presentation/DetailsState.kt @@ -15,6 +15,8 @@ data class DetailsState @OptIn(ExperimentalTime::class) constructor( val calendar: Calendar? = null, val subtasks: List = emptyList(), + val changeBackstack: List = emptyList(), + val allColors: List = emptyList(), val allCategories: List = emptyList(), val latestUsedTimezones: List = emptyList(), diff --git a/shared/src/commonMain/kotlin/at/techbee/spectacled/screens/details/presentation/DetailsViewModel.kt b/shared/src/commonMain/kotlin/at/techbee/spectacled/screens/details/presentation/DetailsViewModel.kt index 3b102d67..a07bda61 100644 --- a/shared/src/commonMain/kotlin/at/techbee/spectacled/screens/details/presentation/DetailsViewModel.kt +++ b/shared/src/commonMain/kotlin/at/techbee/spectacled/screens/details/presentation/DetailsViewModel.kt @@ -133,6 +133,7 @@ class DetailsViewModel( _state.update { it.copy( icalEntry = icalEntry, originalIcalEntry = icalEntry, + changeBackstack = listOf(icalEntry), calendar = calendar, isLoading = false, isInitialized = true, @@ -166,6 +167,7 @@ class DetailsViewModel( _state.update { it.copy( icalEntry = newIcalEntry, originalIcalEntry = newIcalEntry, + changeBackstack = listOf(newIcalEntry), calendar = calendar, isLoading = false, isInitialized = true, @@ -215,6 +217,7 @@ class DetailsViewModel( _state.update { it.copy( icalEntry = copiedIcalEntry, originalIcalEntry = copiedIcalEntry, + changeBackstack = listOf(copiedIcalEntry), isLoading = false, isInitialized = true, navigateUp = false @@ -352,6 +355,7 @@ class DetailsViewModel( onDeleteAttachment(action.attachmentUid) } is DetailsAction.OnUpdateDrawing -> { onUpdateDrawing(action.replaceAttachmentUid, action.paths, action.width, action.height) } + is DetailsAction.OnUndo -> { onUndo() } } } @@ -605,6 +609,7 @@ class DetailsViewModel( _state.update { it.copy( icalEntry = entryToSave, + changeBackstack = it.changeBackstack.plus(entryToSave), showSheetOrDialog = if(navigateUp) null else _state.value.showSheetOrDialog, navigateUp = navigateUp ) @@ -971,4 +976,20 @@ class DetailsViewModel( _state.update { it.copy(showSheetOrDialog = null, isLoading = false, navigateUp = true) } } } + + private fun onUndo() { + + if(!state.value.allowEditing()) + return + + _state.update { + it.copy( + icalEntry = (it.changeBackstack.lastOrNull() ?: it.originalIcalEntry).copy( + lastModified = IcsDateTime.now(), + syncState = it.icalEntry.syncState.afterLocalEdit() + ), + changeBackstack = if(it.changeBackstack.size <= 1) it.changeBackstack else it.changeBackstack.dropLast(1) + ) + } + } } diff --git a/shared/src/commonMain/kotlin/at/techbee/spectacled/screens/details/presentation/components/FormattingBar.kt b/shared/src/commonMain/kotlin/at/techbee/spectacled/screens/details/presentation/components/FormattingBar.kt new file mode 100644 index 00000000..cee8caa9 --- /dev/null +++ b/shared/src/commonMain/kotlin/at/techbee/spectacled/screens/details/presentation/components/FormattingBar.kt @@ -0,0 +1,113 @@ +package at.techbee.spectacled.screens.details.presentation.components + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.outlined.Undo +import androidx.compose.material.icons.filled.FormatBold +import androidx.compose.material.icons.filled.FormatItalic +import androidx.compose.material.icons.filled.FormatUnderlined +import androidx.compose.material.icons.outlined.KeyboardArrowDown +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Surface +import androidx.compose.material3.VerticalDivider +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.focusProperties +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import at.techbee.spectacled.SpectacledVariant +import at.techbee.spectacled.screens.core.presentation.MarkdownFormat +import at.techbee.spectacled.theme.AppTheme +import org.jetbrains.compose.resources.stringResource +import spectacled.shared.generated.resources.Res +import spectacled.shared.generated.resources.format_bold +import spectacled.shared.generated.resources.format_italic +import spectacled.shared.generated.resources.format_underline +import spectacled.shared.generated.resources.undo + +/** + * Slim formatting bar (bold / italic / underline) meant to sit just above the software keyboard. + * The buttons are made non-focusable so tapping them does not steal focus from the editor and hide + * the keyboard. + */ +@Composable +fun FormattingBar( + onFormat: (MarkdownFormat) -> Unit, + onReleaseFocus: () -> Unit, + onUndo: () -> Unit, + modifier: Modifier = Modifier +) { + Surface( + tonalElevation = 3.dp, + shadowElevation = 6.dp, + shape = RoundedCornerShape(topStart = 16.dp, topEnd = 16.dp, bottomStart = 0.dp, bottomEnd = 0.dp), + modifier = modifier.fillMaxWidth() + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(4.dp), + modifier = Modifier.padding(horizontal = 4.dp).fillMaxWidth() + ) { + IconButton( + onClick = { onFormat(MarkdownFormat.BOLD) }, + modifier = Modifier.focusProperties { canFocus = false } + ) { + Icon(Icons.Default.FormatBold, contentDescription = stringResource(Res.string.format_bold)) + } + IconButton( + onClick = { onFormat(MarkdownFormat.ITALIC) }, + modifier = Modifier.focusProperties { canFocus = false } + ) { + Icon(Icons.Default.FormatItalic, contentDescription = stringResource(Res.string.format_italic)) + } + IconButton( + onClick = { onFormat(MarkdownFormat.UNDERLINE) }, + modifier = Modifier.focusProperties { canFocus = false } + ) { + Icon(Icons.Default.FormatUnderlined, contentDescription = stringResource(Res.string.format_underline)) + } + + VerticalDivider(modifier = Modifier.padding(vertical = 4.dp).height(24.dp)) + + IconButton( + onClick = { onUndo() }, + modifier = Modifier.focusProperties { canFocus = false } + ) { + Icon(Icons.AutoMirrored.Outlined.Undo, contentDescription = stringResource(Res.string.undo)) + } + + Spacer(modifier = Modifier.weight(1f)) + + IconButton( + onClick = { onReleaseFocus() }, + modifier = Modifier.focusProperties { canFocus = false } + ) { + Icon(Icons.Outlined.KeyboardArrowDown, null) + } + } + } +} + +@Preview +@Composable +private fun FormattingBar_Preview() { + + AppTheme(spectacledVariant = SpectacledVariant.JOURNALS) { + Scaffold { + FormattingBar( + onFormat = {}, + onReleaseFocus = {}, + onUndo = {} + ) + } + } +} From 4775b86515a6df73faee1dcdb97b2333b1da2c0b Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 29 Sep 2026 10:00:17 +0000 Subject: [PATCH 2/3] Fix undo/changeBackstack race so undo works deterministically saveIcalEntry() pushed the just-saved (current) entry onto changeBackstack, so its last element always duplicated the current state - the first undo click was a no-op. Worse, onUndo() stamped the restored entry with a fresh lastModified, which the 500ms-debounced autosave pipeline treated as a new edit and re-saved ~500ms later, re-appending it onto changeBackstack and undoing the pop. Undo only appeared to work if clicked faster than that autosave delay. onUndo() now targets the checkpoint before the duplicated top of the stack, and marks its own restore (by the timestamp it just set) so saveIcalEntry() can recognize and skip recording that specific follow-up save instead of racing it. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01N4dVR3vYtJjSRNLvzXfHrh --- .../details/presentation/DetailsState.kt | 2 ++ .../details/presentation/DetailsViewModel.kt | 23 +++++++++++++++---- 2 files changed, 21 insertions(+), 4 deletions(-) diff --git a/shared/src/commonMain/kotlin/at/techbee/spectacled/screens/details/presentation/DetailsState.kt b/shared/src/commonMain/kotlin/at/techbee/spectacled/screens/details/presentation/DetailsState.kt index db23e1df..c3ecb28c 100644 --- a/shared/src/commonMain/kotlin/at/techbee/spectacled/screens/details/presentation/DetailsState.kt +++ b/shared/src/commonMain/kotlin/at/techbee/spectacled/screens/details/presentation/DetailsState.kt @@ -1,6 +1,7 @@ package at.techbee.spectacled.screens.details.presentation import androidx.compose.ui.graphics.Color +import at.techbee.spectacled.screens.core.data.ics.IcsDateTime import at.techbee.spectacled.screens.core.domain.Calendar import at.techbee.spectacled.screens.core.domain.CalendarComponent import at.techbee.spectacled.screens.core.domain.HomeCollection @@ -16,6 +17,7 @@ data class DetailsState @OptIn(ExperimentalTime::class) constructor( val subtasks: List = emptyList(), val changeBackstack: List = emptyList(), + val undoRestoreMarker: IcsDateTime? = null, // lastModified stamp of an in-flight undo-restore save, so saveIcalEntry() can recognize and skip it val allColors: List = emptyList(), val allCategories: List = emptyList(), diff --git a/shared/src/commonMain/kotlin/at/techbee/spectacled/screens/details/presentation/DetailsViewModel.kt b/shared/src/commonMain/kotlin/at/techbee/spectacled/screens/details/presentation/DetailsViewModel.kt index a07bda61..4aacad7f 100644 --- a/shared/src/commonMain/kotlin/at/techbee/spectacled/screens/details/presentation/DetailsViewModel.kt +++ b/shared/src/commonMain/kotlin/at/techbee/spectacled/screens/details/presentation/DetailsViewModel.kt @@ -134,6 +134,7 @@ class DetailsViewModel( icalEntry = icalEntry, originalIcalEntry = icalEntry, changeBackstack = listOf(icalEntry), + undoRestoreMarker = null, calendar = calendar, isLoading = false, isInitialized = true, @@ -168,6 +169,7 @@ class DetailsViewModel( icalEntry = newIcalEntry, originalIcalEntry = newIcalEntry, changeBackstack = listOf(newIcalEntry), + undoRestoreMarker = null, calendar = calendar, isLoading = false, isInitialized = true, @@ -218,6 +220,7 @@ class DetailsViewModel( icalEntry = copiedIcalEntry, originalIcalEntry = copiedIcalEntry, changeBackstack = listOf(copiedIcalEntry), + undoRestoreMarker = null, isLoading = false, isInitialized = true, navigateUp = false @@ -607,9 +610,13 @@ class DetailsViewModel( val entryToSave = _state.value.icalEntry.copy(syncState = syncState) _state.update { + // A save caused by onUndo() restoring a prior checkpoint must not be recorded as a + // new one - it would silently reinflate the very stack onUndo() just popped. + val isUndoRestoreSave = it.undoRestoreMarker != null && it.undoRestoreMarker == entryToSave.lastModified it.copy( icalEntry = entryToSave, - changeBackstack = it.changeBackstack.plus(entryToSave), + changeBackstack = if(isUndoRestoreSave) it.changeBackstack else it.changeBackstack.plus(entryToSave), + undoRestoreMarker = if(isUndoRestoreSave) null else it.undoRestoreMarker, showSheetOrDialog = if(navigateUp) null else _state.value.showSheetOrDialog, navigateUp = navigateUp ) @@ -977,18 +984,26 @@ class DetailsViewModel( } } + @OptIn(ExperimentalTime::class) private fun onUndo() { if(!state.value.allowEditing()) return + if(state.value.changeBackstack.size <= 1) // already at the oldest tracked checkpoint + return + + val restoreTimestamp = IcsDateTime.now() _state.update { + val newBackstack = it.changeBackstack.dropLast(1) // drop the entry duplicating current state + val target = newBackstack.last() // the real previous checkpoint it.copy( - icalEntry = (it.changeBackstack.lastOrNull() ?: it.originalIcalEntry).copy( - lastModified = IcsDateTime.now(), + icalEntry = target.copy( + lastModified = restoreTimestamp, syncState = it.icalEntry.syncState.afterLocalEdit() ), - changeBackstack = if(it.changeBackstack.size <= 1) it.changeBackstack else it.changeBackstack.dropLast(1) + changeBackstack = newBackstack, + undoRestoreMarker = restoreTimestamp ) } } From 388dab15bd140bb846c77dcdbbfbdc930c0592d7 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 29 Sep 2026 11:24:56 +0000 Subject: [PATCH 3/3] Skip autosave when entry content hasn't actually changed Every edit handler in DetailsViewModel unconditionally bumped lastModified whenever it was called, on the assumption it's only called on a genuine change. Two UI call sites break that assumption: opening the date or time picker without touching it re-fires onDateSelected/onTimeUpdated with the picker's seeded (unchanged) value via LaunchedEffect running on first composition, and re-tapping an already-selected color/status re-fires those handlers too. Each still triggered the debounced autosave, which pushed a checkpoint onto changeBackstack and made Undo appear active despite nothing having changed. Rather than guard every handler individually, add one content-equality check (IcalEntry.hasSameContentAs, ignoring id/lastModified/syncState/ etag/href) at the single choke point all edits already funnel through: the debounced autosave pipeline. It's skipped only when this save is undo's own restore (tracked via undoRestoreMarker), since undo legitimately restores content that already matches a backstack entry and that save must still reach the database. TimePickerBottomSheet had a second, independent bug its own LaunchedEffect unconditionally cleared isDateOnly even on that same first, untouched firing, so merely opening the time picker on a date-only entry silently gave it a time. That's a genuine content change no equality check can catch, so its LaunchedEffect now skips its first (seed) firing instead. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01N4dVR3vYtJjSRNLvzXfHrh --- .../spectacled/screens/core/domain/IcalEntry.kt | 5 +++++ .../details/presentation/DetailsViewModel.kt | 15 ++++++++++++--- .../components/TimePickerBottomSheet.kt | 10 ++++++++++ 3 files changed, 27 insertions(+), 3 deletions(-) diff --git a/shared/src/commonMain/kotlin/at/techbee/spectacled/screens/core/domain/IcalEntry.kt b/shared/src/commonMain/kotlin/at/techbee/spectacled/screens/core/domain/IcalEntry.kt index 49e8918f..272df030 100644 --- a/shared/src/commonMain/kotlin/at/techbee/spectacled/screens/core/domain/IcalEntry.kt +++ b/shared/src/commonMain/kotlin/at/techbee/spectacled/screens/core/domain/IcalEntry.kt @@ -177,6 +177,11 @@ data class IcalEntry( ) } + // Ignores bookkeeping the app/sync layer maintains on its own (row id, sync state, CalDAV + // etag/href, last-modified stamp) - true if nothing a user could see or edit differs. + fun hasSameContentAs(other: IcalEntry): Boolean = + this.copy(id = other.id, lastModified = other.lastModified, syncState = other.syncState, etag = other.etag, href = other.href) == other + fun getProgressTriState() = when { status == Status.IN_PROCESS -> ToggleableState.Indeterminate status == Status.COMPLETED -> ToggleableState.On diff --git a/shared/src/commonMain/kotlin/at/techbee/spectacled/screens/details/presentation/DetailsViewModel.kt b/shared/src/commonMain/kotlin/at/techbee/spectacled/screens/details/presentation/DetailsViewModel.kt index 4aacad7f..994d6f77 100644 --- a/shared/src/commonMain/kotlin/at/techbee/spectacled/screens/details/presentation/DetailsViewModel.kt +++ b/shared/src/commonMain/kotlin/at/techbee/spectacled/screens/details/presentation/DetailsViewModel.kt @@ -91,11 +91,20 @@ class DetailsViewModel( .debounce(500L.milliseconds) // Wait for 500ms pause in typing .distinctUntilChanged { old, new -> old.lastModified == new.lastModified } // Only save if last modified changed .collect { entry -> - if(!_state.value.isLoading + val current = _state.value + if(!current.isLoading && entry.calendarId != 0L && entry.syncState != SyncState.SYNCED - && entry.syncState != SyncState.LOCAL_NEW) // untouched new entry: don't create a row for it at all - saveIcalEntry(entry.syncState) + && entry.syncState != SyncState.LOCAL_NEW) { // untouched new entry: don't create a row for it at all + // onUndo() bumps lastModified to restore a prior checkpoint even though its + // content already matches that checkpoint - that save must still go through + // so the restore is actually persisted (saveIcalEntry() itself already skips + // re-recording it on the backstack, via undoRestoreMarker). + val isUndoRestore = current.undoRestoreMarker != null && current.undoRestoreMarker == entry.lastModified + val lastCheckpoint = current.changeBackstack.lastOrNull() ?: current.originalIcalEntry + if(isUndoRestore || !entry.hasSameContentAs(lastCheckpoint)) + saveIcalEntry(entry.syncState) + } } } diff --git a/shared/src/commonMain/kotlin/at/techbee/spectacled/screens/details/presentation/components/TimePickerBottomSheet.kt b/shared/src/commonMain/kotlin/at/techbee/spectacled/screens/details/presentation/components/TimePickerBottomSheet.kt index 23ad1e41..b4db862b 100644 --- a/shared/src/commonMain/kotlin/at/techbee/spectacled/screens/details/presentation/components/TimePickerBottomSheet.kt +++ b/shared/src/commonMain/kotlin/at/techbee/spectacled/screens/details/presentation/components/TimePickerBottomSheet.kt @@ -26,6 +26,7 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -64,7 +65,16 @@ fun TimePickerBottomSheet( ) var showTimezoneDropdown by mutableStateOf(false) + // rememberTimePickerState() seeds hour/minute from the entry's current time, and + // LaunchedEffect runs immediately on first composition - without this guard, just opening + // the sheet (without touching the picker) would fire onTimeUpdated with that seeded value. + var hasHandledInitialTime by remember { mutableStateOf(false) } + LaunchedEffect(timePickerState.minute, timePickerState.hour) { + if (!hasHandledInitialTime) { + hasHandledInitialTime = true + return@LaunchedEffect + } val newLocalDateTime = LocalDateTime( year = localDateTime.year, month = localDateTime.month,