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,