Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
80 changes: 73 additions & 7 deletions composeApp/src/commonMain/kotlin/io/github/smiling_pixel/App.kt
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,10 @@ import androidx.compose.material3.IconButton
import androidx.compose.material3.NavigationBar
import androidx.compose.material3.NavigationBarItem
import androidx.compose.material3.Scaffold
import androidx.compose.material3.SnackbarDuration
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.SnackbarResult
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
Expand Down Expand Up @@ -45,6 +49,7 @@ import io.github.smiling_pixel.draft.PlatformDraftExitProtection
import io.github.smiling_pixel.draft.getEntryDraftRepository
import io.github.smiling_pixel.filesystem.FileRepository
import io.github.smiling_pixel.filesystem.InMemoryFileManager
import io.github.smiling_pixel.model.DiaryEntry
import io.github.smiling_pixel.preference.getSettingsRepository
import io.github.smiling_pixel.screens.EntriesScreen
import io.github.smiling_pixel.screens.InsightsScreen
Expand All @@ -54,6 +59,7 @@ import io.github.smiling_pixel.screens.SettingsScreen
import io.github.smiling_pixel.theme.MarkDayTheme
import io.github.smiling_pixel.theme.ThemeMode
import io.github.smiling_pixel.util.Logger
import kotlinx.coroutines.Job
import kotlinx.coroutines.launch
import kotlinx.serialization.Serializable

Expand Down Expand Up @@ -103,6 +109,7 @@ fun App(
val settingsRepository = remember { getSettingsRepository() }
val themeMode by settingsRepository.themeMode.collectAsState(initial = null)
val isPureBlackEnabled by settingsRepository.isPureBlackEnabled.collectAsState(initial = null)
val isCloudSyncEnabled by settingsRepository.isCloudSyncEnabled.collectAsState(initial = false)
val logLevel by settingsRepository.logLevel.collectAsState(initial = null)
val isLogPersistenceEnabled by settingsRepository.isLogPersistenceEnabled.collectAsState(initial = null)

Expand Down Expand Up @@ -132,6 +139,7 @@ fun App(
}
val weatherClient = remember { GoogleWeatherClient(settingsRepository) }
val scope = rememberCoroutineScope()
val snackbarHostState = remember { SnackbarHostState() }
val navController = rememberNavController()
var selected by remember { mutableStateOf<AppRoute>(EntriesRoute) }
// remember previous to return from profile
Expand All @@ -142,6 +150,10 @@ fun App(
var editorExitGuard by remember { mutableStateOf<EditorExitGuard?>(null) }
var showUnsafeNavigationDialog by remember { mutableStateOf(false) }
var pendingNavigation by remember { mutableStateOf<(() -> Unit)?>(null) }
var showDeleteConfirmation by remember { mutableStateOf(false) }
var pendingDeletedEntries by remember { mutableStateOf<List<DiaryEntry>>(emptyList()) }
var undoToken by remember { mutableStateOf(0) }
var undoSnackbarJob by remember { mutableStateOf<Job?>(null) }

PlatformDraftExitProtection(editorExitGuard)
// Desktop owns its Window outside this composable, so publish the same guard used by in-app navigation to the
Expand Down Expand Up @@ -194,6 +206,65 @@ fun App(
)
}

if (showDeleteConfirmation) {
AlertDialog(
onDismissRequest = { showDeleteConfirmation = false },
title = { Text("Delete ${selectedIds.size} entries?") },
text = {
Text(
if (isCloudSyncEnabled) {
"Cloud Sync is enabled. This deletion will also propagate to Google Drive on the next sync."
} else {
"Cloud Sync is disabled. This deletion is local for now, but it may propagate to Google " +
"Drive if Cloud Sync is enabled later."
},
)
},
confirmButton = {
TextButton(
onClick = {
showDeleteConfirmation = false
scope.launch {
val entriesToDelete = repo.getAll().filter { it.syncId in selectedIds }
if (entriesToDelete.isEmpty()) {
isSelectionMode = false
selectedIds = emptySet()
return@launch
}

entriesToDelete.forEach { repo.delete(it) }
isSelectionMode = false
selectedIds = emptySet()
pendingDeletedEntries = entriesToDelete
undoToken += 1
val token = undoToken
undoSnackbarJob?.cancel()
snackbarHostState.currentSnackbarData?.dismiss()
undoSnackbarJob =
scope.launch {
val result =
snackbarHostState.showSnackbar(
message = "${entriesToDelete.size} entries deleted",
actionLabel = "Undo",
duration = SnackbarDuration.Short,
)
if (result == SnackbarResult.ActionPerformed && token == undoToken) {
pendingDeletedEntries.forEach { repo.restore(it) }
}
if (token == undoToken) {
pendingDeletedEntries = emptyList()
}
}
}
},
) { Text("Delete") }
},
dismissButton = {
TextButton(onClick = { showDeleteConfirmation = false }) { Text("Cancel") }
},
)
}

LaunchedEffect(selected) {
isSelectionMode = false
selectedIds = emptySet()
Expand All @@ -204,6 +275,7 @@ fun App(
Modifier
.safeContentPadding()
.fillMaxSize(),
snackbarHost = { SnackbarHost(snackbarHostState) },
topBar = {
if (isSelectionMode) {
CenterAlignedTopAppBar(
Expand All @@ -218,13 +290,7 @@ fun App(
},
actions = {
IconButton(onClick = {
scope.launch {
val currentEntries = repo.entries.value
val entriesToDelete = currentEntries.filter { it.syncId in selectedIds }
entriesToDelete.forEach { repo.delete(it) }
isSelectionMode = false
selectedIds = emptySet()
}
showDeleteConfirmation = true
}) {
Icon(Icons.Default.Delete, contentDescription = "Delete Selected")
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package io.github.smiling_pixel.database
import io.github.smiling_pixel.model.DiaryEntry
import io.github.smiling_pixel.preference.SettingsRepository
import io.github.smiling_pixel.preference.getSettingsRepository
import io.github.smiling_pixel.sync.clearLocalDeletionTombstone
import io.github.smiling_pixel.sync.recordLocalDeletionTombstone
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
Expand Down Expand Up @@ -68,4 +69,22 @@ class DiaryRepository(
}
dao.delete(entry)
}

/**
* Restores a previously deleted diary entry by its stable sync identifier.
*
* If the entry already exists, its current local database ID is preserved. Otherwise, the DAO assigns a new local
* ID. The local sync tombstone is removed only after the entry has been restored successfully.
*
* @param entry The entry snapshot to restore.
*/
suspend fun restore(entry: DiaryEntry) {
val existing = dao.getAll().firstOrNull { it.syncId == entry.syncId }
if (existing == null) {
dao.insert(entry.copy(id = 0))
} else {
dao.update(entry.copy(id = existing.id))
}
clearLocalDeletionTombstone(entry.syncId, settings = settings)
}
Comment thread
SmilingPixel marked this conversation as resolved.
}
Original file line number Diff line number Diff line change
Expand Up @@ -224,6 +224,22 @@ suspend fun recordLocalDeletionTombstone(
}
}

/**
* Clears a local deletion tombstone for an entry that has been restored.
*
* @param syncId Stable cross-device entry identifier.
* @param settings Settings repository containing the persisted tombstones.
*/
suspend fun clearLocalDeletionTombstone(
syncId: String,
settings: SettingsRepository = getSettingsRepository(),
) {
val tombstones = loadLocalDeletionTombstones(settings).toMutableMap()
if (tombstones.remove(syncId) != null) {
saveLocalDeletionTombstones(settings, tombstones)
}
}

private suspend fun getOrCreateFolderByPath(
client: CloudDriveClient,
path: String,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import io.github.smiling_pixel.util.LogLevel
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.test.runTest
import kotlin.test.Test
import kotlin.test.assertEquals
Expand Down Expand Up @@ -216,6 +217,106 @@ class SyncManagerTest {
assertTrue(tombstoneFiles.first().name.startsWith("markday_tombstone_${syncId}_"))
}

@Test
fun restore_reinsertsEntryAndClearsItsTombstone() =
runTest {
val settings = createTestSettingsRepository()
val syncId = "353e4567-e89b-12d3-a456-426614174000"
val entry = diaryEntry(syncId, "Restored", "Content", 1_000, 2_000)
val dao = InMemoryDiaryDao(initial = listOf(entry))
val repo = DiaryRepository(dao, settings = settings)

repo.delete(entry)
recordLocalDeletionTombstone(syncId, deletedAtEpochMillis = 2_000, settings = settings)
repo.restore(entry)

assertEquals(listOf(entry), dao.getAll())
assertEquals(null, settings.cloudSyncDeletionTombstonesJson.first())
}

@Test
fun restore_whenOriginalIdWasReused_assignsNewLocalId() =
runTest {
val settings = createTestSettingsRepository()
val restoredSyncId = "393e4567-e89b-12d3-a456-426614174000"
val replacementSyncId = "3a3e4567-e89b-12d3-a456-426614174000"
val restoredEntry = diaryEntry(restoredSyncId, "Restored", "Original content", 1_000, 2_000)
val replacementEntry = diaryEntry(replacementSyncId, "Replacement", "New content", 3_000, 4_000)
val dao = InMemoryDiaryDao(initial = listOf(replacementEntry))
val repo = DiaryRepository(dao, settings = settings)
recordLocalDeletionTombstone(restoredSyncId, deletedAtEpochMillis = 5_000, settings = settings)

repo.restore(restoredEntry)

val savedEntries = dao.getAll()
assertEquals(2, savedEntries.size)
assertEquals(replacementEntry, savedEntries.first { it.syncId == replacementSyncId })
assertEquals(2, savedEntries.first { it.syncId == restoredSyncId }.id)
assertEquals(null, settings.cloudSyncDeletionTombstonesJson.first())
}

@Test
fun restore_whenSyncIdAlreadyExists_updatesExistingLocalRow() =
runTest {
val settings = createTestSettingsRepository()
val syncId = "3b3e4567-e89b-12d3-a456-426614174000"
val restoredEntry = diaryEntry(syncId, "Restored", "Original content", 1_000, 2_000)
val downloadedEntry = restoredEntry.copy(id = 7, title = "Downloaded", content = "Remote content")
val dao = InMemoryDiaryDao(initial = listOf(downloadedEntry))
val repo = DiaryRepository(dao, settings = settings)
recordLocalDeletionTombstone(syncId, deletedAtEpochMillis = 5_000, settings = settings)

repo.restore(restoredEntry)

val savedEntry = dao.getAll().single()
assertEquals(7, savedEntry.id)
assertEquals(restoredEntry.copy(id = 7), savedEntry)
assertEquals(null, settings.cloudSyncDeletionTombstonesJson.first())
}

@Test
fun clearLocalDeletionTombstone_preservesOtherEntries() =
runTest {
val settings = createTestSettingsRepository()
val firstSyncId = "363e4567-e89b-12d3-a456-426614174000"
val secondSyncId = "373e4567-e89b-12d3-a456-426614174000"

recordLocalDeletionTombstone(firstSyncId, deletedAtEpochMillis = 1_000, settings = settings)
recordLocalDeletionTombstone(secondSyncId, deletedAtEpochMillis = 2_000, settings = settings)
clearLocalDeletionTombstone(firstSyncId, settings)

val tombstones = settings.cloudSyncDeletionTombstonesJson.first()
assertTrue(tombstones != null)
assertTrue(firstSyncId !in tombstones.orEmpty())
assertTrue(secondSyncId in tombstones.orEmpty())
}

@Test
fun restoredEntry_syncsWithoutItsOldDeletionTombstone() =
runTest {
val settings = createTestSettingsRepository()
configureSyncSettings(settings, enabled = true, path = "/")
val syncId = "383e4567-e89b-12d3-a456-426614174000"
val entry = diaryEntry(syncId, "Restored", "Content", 1_000, 2_000)
val dao = InMemoryDiaryDao(initial = listOf(entry))
val repo = DiaryRepository(dao, settings = settings)
val client = FakeCloudDriveClient(authorized = true)
client.seedFile(
name = remoteEntryFileName(syncId, 2_000),
content = encodeEntryForSync(entry),
)

repo.delete(entry)
recordLocalDeletionTombstone(syncId, deletedAtEpochMillis = 2_000, settings = settings)
repo.restore(entry)

val result = performCloudSync(client, repo, dao.getAll(), settings)

assertEquals(SyncResult(uploaded = 0, downloaded = 0, unchanged = 1), result)
assertEquals(listOf(entry), dao.getAll())
assertTrue(client.listFiles(null).any { it.name == remoteEntryFileName(syncId, 2_000) })
}

@Test
fun remoteTombstone_deletesLocalAndDoesNotRestore() =
runTest {
Expand Down
Loading