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
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,11 @@ import androidx.activity.compose.BackHandler
import androidx.compose.runtime.Composable

@Composable
actual fun PlatformDraftExitProtection(guard: EditorExitGuard?) {
BackHandler(enabled = guard != null) {
guard?.requestClose?.invoke()
actual fun PlatformDraftExitProtection(
guard: EditorExitGuard?,
onBackRequest: (() -> Unit)?,
) {
BackHandler(enabled = guard != null || onBackRequest != null) {
guard?.requestClose?.invoke() ?: onBackRequest?.invoke()
}
}
92 changes: 87 additions & 5 deletions composeApp/src/commonMain/kotlin/io/github/smiling_pixel/App.kt
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,11 @@ import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.safeContentPadding
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.AccountCircle
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.filled.Search
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.CenterAlignedTopAppBar
import androidx.compose.material3.ExperimentalMaterial3Api
Expand All @@ -31,6 +33,7 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
Expand All @@ -51,11 +54,15 @@ 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.DiarySyncDialogs
import io.github.smiling_pixel.screens.EntriesScreen
import io.github.smiling_pixel.screens.InsightsScreen
import io.github.smiling_pixel.screens.MomentsScreen
import io.github.smiling_pixel.screens.ProfileScreen
import io.github.smiling_pixel.screens.SearchScreen
import io.github.smiling_pixel.screens.SettingsScreen
import io.github.smiling_pixel.screens.rememberDiarySyncState
import io.github.smiling_pixel.sync.startAutoSync
import io.github.smiling_pixel.theme.MarkDayTheme
import io.github.smiling_pixel.theme.ThemeMode
import io.github.smiling_pixel.util.Logger
Expand All @@ -69,6 +76,10 @@ sealed interface AppRoute
@Serializable
object EntriesRoute : AppRoute

/** Destination for searching and filtering diary entries. */
@Serializable
object SearchRoute : AppRoute

@Serializable
object MomentsRoute : AppRoute

Expand Down Expand Up @@ -139,6 +150,7 @@ fun App(
}
val weatherClient = remember { GoogleWeatherClient(settingsRepository) }
val scope = rememberCoroutineScope()
val diarySyncState = rememberDiarySyncState(repo)
val snackbarHostState = remember { SnackbarHostState() }
val navController = rememberNavController()
var selected by remember { mutableStateOf<AppRoute>(EntriesRoute) }
Expand All @@ -147,6 +159,8 @@ fun App(

var isSelectionMode by remember { mutableStateOf(false) }
var selectedIds by remember { mutableStateOf(emptySet<String>()) }
var isEntriesListVisible by remember { mutableStateOf(false) }
var searchSelectedEntrySyncId by rememberSaveable { mutableStateOf<String?>(null) }
var editorExitGuard by remember { mutableStateOf<EditorExitGuard?>(null) }
var showUnsafeNavigationDialog by remember { mutableStateOf(false) }
var pendingNavigation by remember { mutableStateOf<(() -> Unit)?>(null) }
Expand All @@ -155,7 +169,28 @@ fun App(
var undoToken by remember { mutableStateOf(0) }
var undoSnackbarJob by remember { mutableStateOf<Job?>(null) }

PlatformDraftExitProtection(editorExitGuard)
DisposableEffect(repo) {
val autoSyncJob = startAutoSync(repo)
onDispose { autoSyncJob?.cancel() }
}
DiarySyncDialogs(diarySyncState)

PlatformDraftExitProtection(
guard = editorExitGuard,
onBackRequest =
if (selected == SearchRoute) {
{
if (searchSelectedEntrySyncId != null) {
searchSelectedEntrySyncId = null
} else {
selected = EntriesRoute
navController.popBackStack()
}
}
} else {
null
},
)
// Desktop owns its Window outside this composable, so publish the same guard used by in-app navigation to the
// host. DisposableEffect also clears stale callbacks when the Entries destination leaves composition.
DisposableEffect(editorExitGuard) {
Expand Down Expand Up @@ -302,15 +337,41 @@ fun App(
val title =
when (selected) {
EntriesRoute -> "Entries"
SearchRoute -> "Search"
MomentsRoute -> "Moments"
InsightsRoute -> "Insights"
SettingsRoute -> "Settings"
ProfileRoute -> "Profile"
}
Text(title)
},
navigationIcon = {
if (selected == SearchRoute) {
IconButton(onClick = {
requestNavigation {
if (searchSelectedEntrySyncId != null) {
searchSelectedEntrySyncId = null
} else {
selected = EntriesRoute
navController.popBackStack()
}
}
}) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back")
}
}
},
actions = {
if (selected != ProfileRoute) {
if (selected == EntriesRoute && isEntriesListVisible) {
IconButton(onClick = {
searchSelectedEntrySyncId = null
selected = SearchRoute
navController.navigate(SearchRoute)
}) {
Icon(Icons.Default.Search, contentDescription = "Search entries")
}
}
if (selected != ProfileRoute && selected != SearchRoute) {
IconButton(onClick = {
requestNavigation {
previous = selected
Expand All @@ -328,11 +389,17 @@ fun App(
bottomBar = {
NavigationBar {
NavigationBarItem(
selected = selected == EntriesRoute,
selected = selected == EntriesRoute || selected == SearchRoute,
onClick = {
requestNavigation {
selected = EntriesRoute
navController.navigate(EntriesRoute)
if (selected == SearchRoute) {
searchSelectedEntrySyncId = null
selected = EntriesRoute
navController.popBackStack()
} else {
selected = EntriesRoute
navController.navigate(EntriesRoute)
}
}
},
icon = { Text("E") },
Expand Down Expand Up @@ -385,6 +452,21 @@ fun App(
selectedIds = selectedIds,
onSelectionModeChange = { isSelectionMode = it },
onSelectionChange = { selectedIds = it },
isSyncing = diarySyncState.isSyncing,
onSyncRequest = diarySyncState::requestSync,
onListVisibilityChange = { isEntriesListVisible = it },
onExitGuardChange = { editorExitGuard = it },
)
}
composable<SearchRoute> {
SearchScreen(
repo = repo,
draftRepository = draftRepository,
weatherClient = weatherClient,
selectedEntrySyncId = searchSelectedEntrySyncId,
onSelectedEntryChange = { searchSelectedEntrySyncId = it },
isSyncing = diarySyncState.isSyncing,
onSyncRequest = diarySyncState::requestSync,
onExitGuardChange = { editorExitGuard = it },
)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,14 @@ package io.github.smiling_pixel.draft

import androidx.compose.runtime.Composable

/** Installs platform-specific exit protection for the active [guard]. */
/**
* Installs platform-specific exit protection for the active editor.
*
* @param guard Active editor guard, which takes precedence over ordinary Back behavior.
* @param onBackRequest Optional fallback invoked when the platform handles Back without an active editor guard.
*/
@Composable
expect fun PlatformDraftExitProtection(guard: EditorExitGuard?)
expect fun PlatformDraftExitProtection(
guard: EditorExitGuard?,
onBackRequest: (() -> Unit)? = null,
)
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
package io.github.smiling_pixel.screens

import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import io.github.smiling_pixel.client.getCloudDriveClient
import io.github.smiling_pixel.database.DiaryRepository
import io.github.smiling_pixel.sync.performCloudSync
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch

internal class DiarySyncState(
private val repo: DiaryRepository,
private val scope: CoroutineScope,
) {
var isSyncing by mutableStateOf(false)
private set

var summary by mutableStateOf<String?>(null)
private set

var error by mutableStateOf<String?>(null)
private set

fun requestSync() {
if (isSyncing) return
isSyncing = true
scope.launch {
try {
val result =
performCloudSync(
client = getCloudDriveClient(),
repo = repo,
localEntries = repo.entries.value,
)
summary =
"Sync completed!\nUploaded: ${result.uploaded}\nDownloaded: ${result.downloaded}" +
"\nUnchanged: ${result.unchanged}"
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
error = e.message ?: "An unknown error occurred during sync"
} finally {
isSyncing = false
}
}
}

fun dismissSummary() {
summary = null
}

fun dismissError() {
error = null
}
}

@Composable
internal fun rememberDiarySyncState(repo: DiaryRepository): DiarySyncState {
val scope = rememberCoroutineScope()
return remember(repo, scope) { DiarySyncState(repo, scope) }
}

@Composable
internal fun DiarySyncDialogs(state: DiarySyncState) {
state.summary?.let { summary ->
AlertDialog(
onDismissRequest = state::dismissSummary,
title = { Text("Sync Summary") },
text = { Text(summary) },
confirmButton = { Button(onClick = state::dismissSummary) { Text("OK") } },
)
}

state.error?.let { error ->
AlertDialog(
onDismissRequest = state::dismissError,
title = { Text("Sync Error") },
text = { Text(error) },
confirmButton = { Button(onClick = state::dismissError) { Text("OK") } },
)
}
}
Loading
Loading