From bc8249fdb838a271dc7c94e8d8c4b86c70c68a91 Mon Sep 17 00:00:00 2001 From: dman-os <61868957+dman-os@users.noreply.github.com> Date: Thu, 9 Apr 2026 14:25:44 +0300 Subject: [PATCH 01/16] refactor: kotlin working --- README.md | 4 +- docs/DEVDOC/todo.md | 10 +- .../org/example/daybook/MainActivity.kt | 12 +- .../kotlin/org/example/daybook/App.kt | 569 ++++++++++---- .../org/example/daybook/ConfigViewModel.kt | 2 +- .../org/example/daybook/DrawerViewModel.kt | 4 - .../daybook/capture/screens/CaptureScreen.kt | 52 +- .../example/daybook/drawer/DrawerScreen.kt | 7 +- .../daybook/progress/ProgressScreen.kt | 213 ++++-- .../kotlin/org/example/daybook/ui/facets.kt | 8 - .../daybook/uniffi/core/daybook_core.kt | 702 ++++++++++++------ .../org/example/daybook/uniffi/daybook_ffi.kt | 147 ++-- .../daybook/uniffi/types/daybook_types.kt | 391 ++++++---- .../org/example/daybook/welcome/helpers.kt | 14 +- .../org/example/daybook/welcome/welcome.kt | 60 +- .../kotlin/org/example/daybook/main.kt | 49 +- .../org/example/daybook/MainViewController.kt | 2 +- .../kotlin/org/example/daybook/main.kt | 2 +- src/daybook_core/drawer/cache.rs | 1 + src/daybook_core/index/doc_blobs.rs | 2 +- src/daybook_core/repo.rs | 50 ++ src/daybook_core/rt.rs | 193 ++++- src/daybook_core/rt/init.rs | 53 ++ src/daybook_core/test_support.rs | 1 + src/daybook_ffi/ffi.rs | 1 + src/daybook_ffi/repos/config.rs | 13 +- src/daybook_ffi/repos/dispatch.rs | 13 +- src/daybook_ffi/repos/drawer.rs | 13 +- src/daybook_ffi/repos/plugs.rs | 17 +- src/daybook_ffi/repos/progress.rs | 13 +- src/daybook_ffi/repos/sync.rs | 27 +- src/daybook_ffi/repos/tables.rs | 13 +- src/daybook_ffi/rt.rs | 19 +- src/utils_rs/lib.rs | 16 + 34 files changed, 1902 insertions(+), 791 deletions(-) diff --git a/README.md b/README.md index 913aa42c..23364788 100644 --- a/README.md +++ b/README.md @@ -13,8 +13,8 @@ Experimental. > > #### what's in the oven ‍‍👩🏿‍🍳? > -> - `mltools` base. -> - This is where supporting platform for ML based features will go. +> - Our first feature! +> - Agent sanboxing/orchestration. ## 🌞 Daybook diff --git a/docs/DEVDOC/todo.md b/docs/DEVDOC/todo.md index bdb68a77..220ff9ff 100644 --- a/docs/DEVDOC/todo.md +++ b/docs/DEVDOC/todo.md @@ -23,6 +23,9 @@ ## Stack +- [ ] Good and local document classification + - [ ] Receipt parsing + - [ ] Collators - [ ] Sync panel - [ ] P2P - [x] iroh @@ -35,9 +38,6 @@ - [ ] Blobs server - [x] Iroh blobs? - [x] Chunked hashing for transfers -- [ ] Good and local document classification - - [ ] Receipt parsing - - [ ] Collators - [ ] daybook_server - [ ] Decide on wrpc vs json - [ ] DrawerScreen @@ -45,14 +45,14 @@ - [ ] Press again to change views - [ ] Mltools config should be per device - [ ] Processors should only run on device that created the doc -- [ ] use a env var or an env! var to set global multipliers for our test timeouts +- [x] use a env var or an env! var to set global multipliers for our test timeouts - [ ] File locks on repo - [ ] Store plugin info in drawer?? - [ ] Js execution - [ ] https://json-render.dev/ based display - [ ] DocEditor - [x] Progress system - - [ ] Blob download + - [x] Blob download - [x] Overhaul bottom bar - [ ] Change color when in different modes. - [ ] Experiment with floating bottom bar diff --git a/src/daybook_compose/composeApp/src/androidMain/kotlin/org/example/daybook/MainActivity.kt b/src/daybook_compose/composeApp/src/androidMain/kotlin/org/example/daybook/MainActivity.kt index 7a4c0cab..5560f0cd 100644 --- a/src/daybook_compose/composeApp/src/androidMain/kotlin/org/example/daybook/MainActivity.kt +++ b/src/daybook_compose/composeApp/src/androidMain/kotlin/org/example/daybook/MainActivity.kt @@ -66,9 +66,11 @@ class MainActivity : ComponentActivity() { @Composable fun AndroidApp() { val context = LocalContext.current + val activity = context as? ComponentActivity var hasOverlayPermission by remember { mutableStateOf(Settings.canDrawOverlays(context)) } var permissionRefreshTick by remember { mutableIntStateOf(0) } + var shutdownRequested by remember { mutableStateOf(false) } val permissionLauncher = rememberLauncherForActivityResult(ActivityResultContracts.RequestMultiplePermissions()) { @@ -109,6 +111,11 @@ fun AndroidApp() { if (event == Lifecycle.Event.ON_RESUME) { hasOverlayPermission = Settings.canDrawOverlays(context) permissionRefreshTick += 1 + } else if (event == Lifecycle.Event.ON_DESTROY) { + // Ignore config-change destroys; shut down only when activity is actually finishing. + if (activity?.isFinishing != false) { + shutdownRequested = true + } } } lifecycleOwner.lifecycle.addObserver(observer) @@ -229,7 +236,10 @@ fun AndroidApp() { if (hasOverlayPermission) { startMagicWandService(context) } - } + }, + shutdownRequested = shutdownRequested, + onShutdownCompleted = {}, + autoShutdownOnDispose = false ) } } diff --git a/src/daybook_compose/composeApp/src/commonMain/kotlin/org/example/daybook/App.kt b/src/daybook_compose/composeApp/src/commonMain/kotlin/org/example/daybook/App.kt index 55482d33..75832752 100644 --- a/src/daybook_compose/composeApp/src/commonMain/kotlin/org/example/daybook/App.kt +++ b/src/daybook_compose/composeApp/src/commonMain/kotlin/org/example/daybook/App.kt @@ -83,6 +83,11 @@ import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.CancellationException import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withContext +import kotlinx.coroutines.withTimeout +import kotlin.time.Clock +import kotlin.time.TimeMark +import kotlin.time.TimeSource import io.github.vinceglb.filekit.dialogs.compose.rememberDirectoryPickerLauncher import io.github.vinceglb.filekit.path import org.example.daybook.capture.CameraCaptureContext @@ -116,8 +121,14 @@ import org.example.daybook.uniffi.TablesRepoFfi import org.example.daybook.uniffi.core.KnownRepoEntry import org.example.daybook.uniffi.core.ListenerRegistration import org.example.daybook.uniffi.core.Panel +import org.example.daybook.uniffi.core.CreateProgressTaskArgs +import org.example.daybook.uniffi.core.ProgressFinalState +import org.example.daybook.uniffi.core.ProgressRetentionPolicy +import org.example.daybook.uniffi.core.ProgressSeverity import org.example.daybook.uniffi.core.ProgressTask import org.example.daybook.uniffi.core.ProgressTaskState +import org.example.daybook.uniffi.core.ProgressUnit +import org.example.daybook.uniffi.core.ProgressUpdate import org.example.daybook.uniffi.core.ProgressUpdateDeets import org.example.daybook.uniffi.core.Tab import org.example.daybook.uniffi.core.Table @@ -168,11 +179,11 @@ data class AppContainer( val tablesRepo: TablesRepoFfi, val dispatchRepo: DispatchRepoFfi, val progressRepo: ProgressRepoFfi, - val rtFfi: RtFfi, + val rtFfi: RtFfi?, val plugsRepo: org.example.daybook.uniffi.PlugsRepoFfi, val configRepo: ConfigRepoFfi, val blobsRepo: org.example.daybook.uniffi.BlobsRepoFfi, - val syncRepo: SyncRepoFfi, + val syncRepo: SyncRepoFfi?, val cameraPreviewFfi: CameraPreviewFfi ) @@ -530,15 +541,159 @@ private suspend fun warmUpTablesRepo(tablesRepo: TablesRepoFfi) { tablesRepo.listTables() } +private const val STARTUP_PROGRESS_TASK_ID = "app/init/startup" + +private class StartupProgressTask( + private val progressRepo: ProgressRepoFfi, + val taskId: String, + private val appElapsedMillis: () -> Long, + private val totalStages: Int +) { + private var doneStages = 0 + + suspend fun begin(repoPath: String, startupElapsed: String, phaseId: String) { + progressRepo.upsertTask( + CreateProgressTaskArgs( + id = taskId, + tags = listOf("/app/init", "/app/init/open-repo"), + retention = ProgressRetentionPolicy.UserDismissable + ) + ) + progressRepo.addUpdate( + taskId, + ProgressUpdate( + at = Clock.System.now(), + title = "App startup", + deets = + ProgressUpdateDeets.Status( + severity = ProgressSeverity.INFO, + message = + "startup phase begin phase=$phaseId (open_elapsed=$startupElapsed from_app_start_ms=${appElapsedMillis()})" + ) + ) + ) + progressRepo.addUpdate( + taskId, + ProgressUpdate( + at = Clock.System.now(), + title = "App startup", + deets = + ProgressUpdateDeets.Status( + severity = ProgressSeverity.INFO, + message = + "opening repo $repoPath (open_elapsed=$startupElapsed from_app_start_ms=${appElapsedMillis()})" + ) + ) + ) + } + + suspend fun stageStart(stage: String, startupElapsed: String) { + progressRepo.addUpdate( + taskId, + ProgressUpdate( + at = Clock.System.now(), + title = "App startup", + deets = + ProgressUpdateDeets.Status( + severity = ProgressSeverity.INFO, + message = + "starting $stage (open_elapsed=$startupElapsed from_app_start_ms=${appElapsedMillis()})" + ) + ) + ) + } + + suspend fun stageDone(stage: String, stageElapsed: String, startupElapsed: String) { + doneStages += 1 + progressRepo.addUpdate( + taskId, + ProgressUpdate( + at = Clock.System.now(), + title = "App startup", + deets = + ProgressUpdateDeets.Amount( + severity = ProgressSeverity.INFO, + done = doneStages.toULong(), + total = totalStages.toULong(), + unit = ProgressUnit.Generic("stage"), + message = + "$stage done in $stageElapsed (open_elapsed=$startupElapsed from_app_start_ms=${appElapsedMillis()})" + ) + ) + ) + } + + suspend fun fail(message: String, startupElapsed: String) { + progressRepo.addUpdate( + taskId, + ProgressUpdate( + at = Clock.System.now(), + title = "App startup", + deets = + ProgressUpdateDeets.Completed( + state = ProgressFinalState.FAILED, + message = + "$message (open_elapsed=$startupElapsed from_app_start_ms=${appElapsedMillis()})" + ) + ) + ) + } + + suspend fun complete(startupElapsed: String) { + progressRepo.addUpdate( + taskId, + ProgressUpdate( + at = Clock.System.now(), + title = "App startup", + deets = + ProgressUpdateDeets.Completed( + state = ProgressFinalState.SUCCEEDED, + message = + "startup complete (open_elapsed=$startupElapsed from_app_start_ms=${appElapsedMillis()})" + ) + ) + ) + } +} + +private suspend fun withStartupStage( + stage: String, + startupMark: TimeMark, + progress: StartupProgressTask?, + block: suspend () -> T +): T { + val stageMark = TimeSource.Monotonic.markNow() + progress?.stageStart(stage, startupMark.elapsedNow().toString()) + try { + val out = block() + progress?.stageDone( + stage, + stageMark.elapsedNow().toString(), + startupMark.elapsedNow().toString() + ) + return out + } catch (t: Throwable) { + progress?.fail( + "stage $stage failed: ${t.message ?: "unknown error"}", + startupMark.elapsedNow().toString() + ) + throw t + } +} + @Composable @Preview fun App( config: AppConfig = AppConfig(), surfaceModifier: Modifier = Modifier, extraAction: (() -> Unit)? = null, - navController: NavHostController = rememberNavController() + navController: NavHostController = rememberNavController(), + shutdownRequested: Boolean = false, + onShutdownCompleted: (() -> Unit)? = null, + autoShutdownOnDispose: Boolean = true ) { val permCtx = LocalPermCtx.current + val appStartMark = remember { TimeSource.Monotonic.markNow() } var initAttempt by remember { mutableStateOf(0) } var initState by remember { mutableStateOf(AppInitState.Loading) } var pendingOpenRepoPath by remember { mutableStateOf(null) } @@ -549,12 +704,26 @@ fun App( var createRepoInitRequest by remember { mutableStateOf(null) } var selectedWelcomeRepo by remember { mutableStateOf(null) } var pendingForgetRepoId by remember { mutableStateOf(null) } - val cloneCameraPreviewFfi = remember { CameraPreviewFfi.load() } + var cloneCameraPreviewFfi by remember { mutableStateOf(null) } val ffiServices = rememberAppFfiServices() - androidx.compose.runtime.DisposableEffect(Unit) { + LaunchedEffect(initState, cloneCameraPreviewFfi) { + // Defer optional camera preload until after initial app bootstrap to avoid FFI init races. + val canPreloadCamera = + initState is AppInitState.Welcome || initState is AppInitState.Ready + if (!canPreloadCamera || cloneCameraPreviewFfi != null) return@LaunchedEffect + runCatching { + withContext(Dispatchers.IO) { CameraPreviewFfi.load() } + }.onSuccess { loaded -> + cloneCameraPreviewFfi = loaded + }.onFailure { error -> + println("[APP_INIT] CameraPreview preload failed: ${error.message}") + } + } + + androidx.compose.runtime.DisposableEffect(cloneCameraPreviewFfi) { onDispose { - cloneCameraPreviewFfi.close() + cloneCameraPreviewFfi?.close() } } @@ -562,7 +731,12 @@ fun App( initState = AppInitState.Loading selectedWelcomeRepo = null try { - val repoConfig = ffiServices.getRepoConfig() + println("[APP_INIT] stage=getRepoConfig start") + val repoConfig = + withTimeout(20_000) { + withContext(Dispatchers.IO) { ffiServices.getRepoConfig() } + } + println("[APP_INIT] stage=getRepoConfig done") val knownRepos = repoConfig.knownRepos val lastUsedRepo = repoConfig.lastUsedRepoId?.let { lastUsedRepoId -> @@ -573,7 +747,13 @@ fun App( false } else { val lastUsedRepoPath = lastUsedRepo.path - ffiServices.isRepoUsable(lastUsedRepoPath) + println("[APP_INIT] stage=isRepoUsable start path=$lastUsedRepoPath") + val usable = + withTimeout(20_000) { + withContext(Dispatchers.IO) { ffiServices.isRepoUsable(lastUsedRepoPath) } + } + println("[APP_INIT] stage=isRepoUsable done usable=$usable") + usable } if (shouldOpenLastUsedRepo && lastUsedRepo != null) { @@ -585,110 +765,113 @@ fun App( } catch (throwable: Throwable) { if (throwable is CancellationException) throw throwable cloneSourceUrlPendingOpen = null + println("[APP_INIT] stage=bootstrap failed err=${throwable.message}") initState = AppInitState.Error(throwable) } } LaunchedEffect(pendingOpenRepoPath) { val repoPath = pendingOpenRepoPath ?: return@LaunchedEffect - var fcx: FfiCtx? = null - var tablesRepo: TablesRepoFfi? = null - var blobsRepo: org.example.daybook.uniffi.BlobsRepoFfi? = null - var plugsRepo: org.example.daybook.uniffi.PlugsRepoFfi? = null - var drawerRepo: DrawerRepoFfi? = null - var configRepo: ConfigRepoFfi? = null - var dispatchRepo: DispatchRepoFfi? = null - var progressRepo: ProgressRepoFfi? = null - var syncRepo: SyncRepoFfi? = null - var rtFfi: RtFfi? = null - var cameraPreviewFfi: CameraPreviewFfi? = null + val startupMark = TimeSource.Monotonic.markNow() + val startupPhaseId = Clock.System.now().toEpochMilliseconds().toString() + val startupStageCount = 7 try { initState = AppInitState.OpeningRepo(repoPath = repoPath) - fcx = ffiServices.openRepoFfiCtx(repoPath) - val fcxReady = fcx ?: error("ffi context initialization failed") - tablesRepo = TablesRepoFfi.load(fcx = fcxReady) - blobsRepo = - org.example.daybook.uniffi.BlobsRepoFfi - .load(fcx = fcxReady) - plugsRepo = - org.example.daybook.uniffi.PlugsRepoFfi - .load(fcx = fcxReady, blobsRepo = blobsRepo ?: error("blobs repo failed to load")) - drawerRepo = - DrawerRepoFfi.load(fcx = fcxReady, plugsRepo = plugsRepo ?: error("plugs repo failed to load")) - configRepo = - ConfigRepoFfi.load(fcx = fcxReady, plugRepo = plugsRepo ?: error("plugs repo failed to load")) - dispatchRepo = DispatchRepoFfi.load(fcx = fcxReady) - progressRepo = ProgressRepoFfi.load(fcx = fcxReady) - syncRepo = - SyncRepoFfi.load( - fcx = fcxReady, - configRepo = configRepo ?: error("config repo failed to load"), - blobsRepo = blobsRepo ?: error("blobs repo failed to load"), - drawerRepo = drawerRepo ?: error("drawer repo failed to load"), - progressRepo = progressRepo ?: error("progress repo failed to load") - ) - rtFfi = - RtFfi.load( - fcx = fcxReady, - drawerRepo = drawerRepo ?: error("drawer repo failed to load"), - plugsRepo = plugsRepo ?: error("plugs repo failed to load"), - dispatchRepo = dispatchRepo ?: error("dispatch repo failed to load"), - progressRepo = progressRepo ?: error("progress repo failed to load"), - blobsRepo = blobsRepo ?: error("blobs repo failed to load"), - configRepo = configRepo ?: error("config repo failed to load"), - deviceId = "compose-client" - ) - cameraPreviewFfi = CameraPreviewFfi.load() - warmUpTablesRepo(tablesRepo ?: error("tables repo failed to load")) - - initState = - AppInitState.Ready( + val container = withContext(Dispatchers.IO) { + var fcx: FfiCtx? = null + var tablesRepo: TablesRepoFfi? = null + var blobsRepo: org.example.daybook.uniffi.BlobsRepoFfi? = null + var plugsRepo: org.example.daybook.uniffi.PlugsRepoFfi? = null + var drawerRepo: DrawerRepoFfi? = null + var configRepo: ConfigRepoFfi? = null + var dispatchRepo: DispatchRepoFfi? = null + var progressRepo: ProgressRepoFfi? = null + var cameraPreviewFfi: CameraPreviewFfi? = null + var startupProgress: StartupProgressTask? = null + try { + fcx = + withStartupStage("ffiServices.openRepoFfiCtx", startupMark, null) { + ffiServices.openRepoFfiCtx(repoPath) + } + val fcxReady = fcx ?: error("ffi context initialization failed") + progressRepo = + withStartupStage("ProgressRepoFfi.load", startupMark, null) { + ProgressRepoFfi.load(fcx = fcxReady) + } + startupProgress = + StartupProgressTask( + progressRepo = progressRepo ?: error("progress repo failed to load"), + taskId = STARTUP_PROGRESS_TASK_ID, + appElapsedMillis = { appStartMark.elapsedNow().inWholeMilliseconds }, + totalStages = startupStageCount + ) + startupProgress.begin(repoPath, startupMark.elapsedNow().toString(), startupPhaseId) + tablesRepo = + withStartupStage("TablesRepoFfi.load", startupMark, startupProgress) { + TablesRepoFfi.load(fcx = fcxReady) + } + blobsRepo = + withStartupStage("BlobsRepoFfi.load", startupMark, startupProgress) { + org.example.daybook.uniffi.BlobsRepoFfi.load(fcx = fcxReady) + } + plugsRepo = + withStartupStage("PlugsRepoFfi.load", startupMark, startupProgress) { + org.example.daybook.uniffi.PlugsRepoFfi + .load(fcx = fcxReady, blobsRepo = blobsRepo ?: error("blobs repo failed to load")) + } + drawerRepo = + withStartupStage("DrawerRepoFfi.load", startupMark, startupProgress) { + DrawerRepoFfi.load(fcx = fcxReady, plugsRepo = plugsRepo ?: error("plugs repo failed to load")) + } + configRepo = + withStartupStage("ConfigRepoFfi.load", startupMark, startupProgress) { + ConfigRepoFfi.load(fcx = fcxReady, plugRepo = plugsRepo ?: error("plugs repo failed to load")) + } + dispatchRepo = + withStartupStage("DispatchRepoFfi.load", startupMark, startupProgress) { + DispatchRepoFfi.load(fcx = fcxReady) + } + cameraPreviewFfi = + withStartupStage("CameraPreviewFfi.load", startupMark, startupProgress) { + CameraPreviewFfi.load() + } + withStartupStage("warmUpTablesRepo", startupMark, startupProgress) { + warmUpTablesRepo(tablesRepo ?: error("tables repo failed to load")) + } + startupProgress.complete(startupMark.elapsedNow().toString()) AppContainer( ffiCtx = fcxReady, drawerRepo = drawerRepo ?: error("drawer repo failed to load"), tablesRepo = tablesRepo ?: error("tables repo failed to load"), dispatchRepo = dispatchRepo ?: error("dispatch repo failed to load"), progressRepo = progressRepo ?: error("progress repo failed to load"), - rtFfi = rtFfi ?: error("rt ffi failed to load"), + rtFfi = null, plugsRepo = plugsRepo ?: error("plugs repo failed to load"), configRepo = configRepo ?: error("config repo failed to load"), blobsRepo = blobsRepo ?: error("blobs repo failed to load"), - syncRepo = syncRepo ?: error("sync repo failed to load"), + syncRepo = null, cameraPreviewFfi = cameraPreviewFfi ?: error("camera preview ffi failed to load") ) - ) - tablesRepo = null - blobsRepo = null - plugsRepo = null - drawerRepo = null - configRepo = null - dispatchRepo = null - progressRepo = null - syncRepo = null - rtFfi = null - cameraPreviewFfi = null - fcx = null - } catch (throwable: Throwable) { - if (throwable is CancellationException) throw throwable - cameraPreviewFfi?.close() - try { - val syncRepoRef = syncRepo - if (syncRepoRef != null) { - syncRepoRef.stop() + } catch (throwable: Throwable) { + if (throwable is CancellationException) throw throwable + val startupErrorMessage = throwable.message ?: throwable::class.simpleName ?: "unknown error" + startupProgress?.fail(startupErrorMessage, startupMark.elapsedNow().toString()) + cameraPreviewFfi?.close() + progressRepo?.close() + dispatchRepo?.close() + drawerRepo?.close() + tablesRepo?.close() + plugsRepo?.close() + configRepo?.close() + blobsRepo?.close() + fcx?.close() + throw throwable } - } catch (cleanupError: Throwable) { - throwable.addSuppressed(cleanupError) } - syncRepo?.close() - progressRepo?.close() - dispatchRepo?.close() - rtFfi?.close() - drawerRepo?.close() - tablesRepo?.close() - plugsRepo?.close() - configRepo?.close() - blobsRepo?.close() - fcx?.close() + initState = + AppInitState.Ready(container) + } catch (throwable: Throwable) { + if (throwable is CancellationException) throw throwable cloneSourceUrlPendingOpen = null initState = AppInitState.Error(throwable) } finally { @@ -699,7 +882,7 @@ fun App( LaunchedEffect(pendingForgetRepoId) { val repoId = pendingForgetRepoId ?: return@LaunchedEffect try { - val repoConfig = ffiServices.forgetKnownRepo(repoId) + val repoConfig = withContext(Dispatchers.IO) { ffiServices.forgetKnownRepo(repoId) } selectedWelcomeRepo = null initState = AppInitState.Welcome(repos = repoConfig.knownRepos) } catch (throwable: Throwable) { @@ -709,6 +892,46 @@ fun App( } } + LaunchedEffect(initState) { + val ready = initState as? AppInitState.Ready ?: return@LaunchedEffect + val current = ready.container + if (current.syncRepo != null && current.rtFfi != null) return@LaunchedEffect + + try { + println("[APP_INIT] stage=deferred SyncRepoFfi.load start") + val syncRepo = withContext(Dispatchers.IO) { + SyncRepoFfi.load( + fcx = current.ffiCtx, + configRepo = current.configRepo, + blobsRepo = current.blobsRepo, + drawerRepo = current.drawerRepo, + progressRepo = current.progressRepo + ) + } + println("[APP_INIT] stage=deferred SyncRepoFfi.load done") + + println("[APP_INIT] stage=deferred RtFfi.load start") + val rtFfi = withContext(Dispatchers.IO) { + RtFfi.load( + fcx = current.ffiCtx, + drawerRepo = current.drawerRepo, + plugsRepo = current.plugsRepo, + dispatchRepo = current.dispatchRepo, + progressRepo = current.progressRepo, + blobsRepo = current.blobsRepo, + configRepo = current.configRepo, + deviceId = "compose-client", + startupProgressTaskId = STARTUP_PROGRESS_TASK_ID + ) + } + println("[APP_INIT] stage=deferred RtFfi.load done") + initState = AppInitState.Ready(current.copy(syncRepo = syncRepo, rtFfi = rtFfi)) + } catch (throwable: Throwable) { + if (throwable is CancellationException) throw throwable + initState = AppInitState.Error(throwable) + } + } + DaybookTheme(themeConfig = config.theme) { when (val state = initState) { is AppInitState.Loading -> { @@ -765,62 +988,80 @@ fun App( is AppInitState.Ready -> { val appContainer = state.container + var shutdownDone by remember(appContainer.ffiCtx) { mutableStateOf(false) } + + LaunchedEffect(shutdownRequested, appContainer.ffiCtx, shutdownDone) { + if (shutdownRequested && !shutdownDone) { + shutdownAppContainer(appContainer) + shutdownDone = true + onShutdownCompleted?.invoke() + } + } // Ensure FFI resources are closed when the composition leaves - androidx.compose.runtime.DisposableEffect(appContainer) { + androidx.compose.runtime.DisposableEffect(appContainer.ffiCtx) { onDispose { - runBlocking(Dispatchers.IO) { - runCatching { appContainer.syncRepo.stop() } - .onFailure { error -> println("sync repo stop failed: $error") } - runCatching { appContainer.progressRepo.stop() } - .onFailure { error -> println("progress repo stop failed: $error") } + if (!autoShutdownOnDispose) { + return@onDispose + } + if (!shutdownDone) { + runBlocking(Dispatchers.IO) { + shutdownAppContainer(appContainer) + } } - appContainer.drawerRepo.close() - appContainer.tablesRepo.close() - appContainer.dispatchRepo.close() - appContainer.progressRepo.close() - appContainer.rtFfi.close() - appContainer.plugsRepo.close() - appContainer.configRepo.close() - appContainer.blobsRepo.close() - appContainer.syncRepo.close() - appContainer.cameraPreviewFfi.close() - appContainer.ffiCtx.close() } } - CompositionLocalProvider( - LocalContainer provides appContainer - ) { - val syncingState = cloneUiState as? CloneUiState.Syncing - if (syncingState != null) { - CloneSyncScreen( - progressRepo = appContainer.progressRepo, - state = syncingState, - onSyncInBackground = { - if (syncingState.initialSyncComplete) { - cloneUiState = null + Box(modifier = Modifier.fillMaxSize()) { + CompositionLocalProvider( + LocalContainer provides appContainer + ) { + val syncingState = cloneUiState as? CloneUiState.Syncing + if (syncingState != null) { + CloneSyncScreen( + progressRepo = appContainer.progressRepo, + state = syncingState, + onSyncInBackground = { + if (syncingState.initialSyncComplete) { + cloneUiState = null + } + }, + onRetry = { + cloneSourceUrlPendingOpen = syncingState.sourceUrl + } + ) + } else { + // Provide camera capture context for coordination between camera and bottom bar + val cameraCaptureContext = remember { CameraCaptureContext() } + val chromeStateManager = remember { ChromeStateManager() } + ProvideCameraCaptureContext(cameraCaptureContext) { + CompositionLocalProvider( + LocalChromeStateManager provides chromeStateManager + ) { + val bigDialogState = remember { BigDialogState() } + AdaptiveAppLayout( + modifier = surfaceModifier, + navController = navController, + extraAction = extraAction, + bigDialogState = bigDialogState + ) } - }, - onRetry = { - cloneSourceUrlPendingOpen = syncingState.sourceUrl } - ) - } else { - // Provide camera capture context for coordination between camera and bottom bar - val cameraCaptureContext = remember { CameraCaptureContext() } - val chromeStateManager = remember { ChromeStateManager() } - ProvideCameraCaptureContext(cameraCaptureContext) { - CompositionLocalProvider( - LocalChromeStateManager provides chromeStateManager - ) { - val bigDialogState = remember { BigDialogState() } - AdaptiveAppLayout( - modifier = surfaceModifier, - navController = navController, - extraAction = extraAction, - bigDialogState = bigDialogState - ) + } + } + if (shutdownRequested && !shutdownDone) { + Surface( + modifier = Modifier.fillMaxSize(), + color = MaterialTheme.colorScheme.surface + ) { + Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + CircularProgressIndicator() + Text("Shutting down…", style = MaterialTheme.typography.bodyLarge) + } } } } @@ -833,6 +1074,17 @@ fun App( LaunchedEffect(initState, cloneSourceUrlPendingOpen) { val ready = initState as? AppInitState.Ready ?: return@LaunchedEffect val sourceUrl = cloneSourceUrlPendingOpen ?: return@LaunchedEffect + val syncRepo = ready.container.syncRepo + if (syncRepo == null) { + cloneUiState = + CloneUiState.Syncing( + sourceUrl = sourceUrl, + initialSyncComplete = false, + phaseMessage = "Starting sync services…", + errorMessage = null + ) + return@LaunchedEffect + } cloneUiState = CloneUiState.Syncing( sourceUrl = sourceUrl, @@ -841,7 +1093,7 @@ fun App( errorMessage = null ) try { - ready.container.syncRepo.connectUrl(sourceUrl) + syncRepo.connectUrl(sourceUrl) val current = cloneUiState as? CloneUiState.Syncing if (current != null && current.sourceUrl == sourceUrl) { cloneUiState = @@ -875,6 +1127,34 @@ fun App( } } +private suspend fun shutdownAppContainer(appContainer: AppContainer) { + println("[APP_SHUTDOWN] flushing to disk: begin") + val syncRepo = appContainer.syncRepo + if (syncRepo != null) { + withContext(Dispatchers.IO) { + println("[APP_SHUTDOWN] flushing to disk: stopping sync repo") + syncRepo.stop() + } + } + withContext(Dispatchers.IO) { + println("[APP_SHUTDOWN] flushing to disk: stopping progress repo") + appContainer.progressRepo.stop() + } + println("[APP_SHUTDOWN] flushing to disk: closing repo handles") + appContainer.drawerRepo.close() + appContainer.tablesRepo.close() + appContainer.dispatchRepo.close() + appContainer.progressRepo.close() + appContainer.rtFfi?.close() + appContainer.plugsRepo.close() + appContainer.configRepo.close() + appContainer.blobsRepo.close() + syncRepo?.close() + appContainer.cameraPreviewFfi.close() + appContainer.ffiCtx.close() + println("[APP_SHUTDOWN] flushing to disk: complete") +} + @Composable fun AdaptiveAppLayout( modifier: Modifier = Modifier, @@ -1147,6 +1427,9 @@ fun Routes( extraAction: (() -> Unit)? = null, navController: NavHostController ) { + val container = LocalContainer.current + val drawerVm: DrawerViewModel = viewModel { DrawerViewModel(container.drawerRepo) } + NavHost( startDestination = AppScreens.Home.name, navController = navController @@ -1170,7 +1453,11 @@ fun Routes( } composable(route = AppScreens.Drawer.name) { ProvideChromeState(ChromeState(title = "Drawer")) { - DrawerScreen(modifier = modifier, contentType = contentType) + DrawerScreen( + drawerVm = drawerVm, + modifier = modifier, + contentType = contentType + ) } } composable(route = AppScreens.Home.name) { diff --git a/src/daybook_compose/composeApp/src/commonMain/kotlin/org/example/daybook/ConfigViewModel.kt b/src/daybook_compose/composeApp/src/commonMain/kotlin/org/example/daybook/ConfigViewModel.kt index 9b23137d..531f9d51 100644 --- a/src/daybook_compose/composeApp/src/commonMain/kotlin/org/example/daybook/ConfigViewModel.kt +++ b/src/daybook_compose/composeApp/src/commonMain/kotlin/org/example/daybook/ConfigViewModel.kt @@ -17,12 +17,12 @@ import org.example.daybook.uniffi.FfiException import org.example.daybook.uniffi.NoHandle import org.example.daybook.uniffi.ProgressEventListener import org.example.daybook.uniffi.ProgressRepoFfi -import org.example.daybook.uniffi.core.FacetDisplayHint import org.example.daybook.uniffi.core.ListenerRegistration import org.example.daybook.uniffi.core.ProgressEvent import org.example.daybook.uniffi.core.ProgressTaskState import org.example.daybook.uniffi.core.ProgressUpdateDeets import org.example.daybook.uniffi.core.ProgressTask +import org.example.daybook.uniffi.types.FacetDisplayHint data class ConfigError(val message: String, val exception: FfiException) diff --git a/src/daybook_compose/composeApp/src/commonMain/kotlin/org/example/daybook/DrawerViewModel.kt b/src/daybook_compose/composeApp/src/commonMain/kotlin/org/example/daybook/DrawerViewModel.kt index 299dd890..cbbd9b0d 100644 --- a/src/daybook_compose/composeApp/src/commonMain/kotlin/org/example/daybook/DrawerViewModel.kt +++ b/src/daybook_compose/composeApp/src/commonMain/kotlin/org/example/daybook/DrawerViewModel.kt @@ -84,10 +84,6 @@ class DrawerViewModel(val drawerRepo: DrawerRepoFfi) : ViewModel() { override fun onDrawerEvent(event: DrawerEvent) { viewModelScope.launch { when (event) { - is DrawerEvent.ListChanged -> { - refreshRunner.submit(DrawerRefreshIntent.ListOnly) - } - is DrawerEvent.DocAdded -> { refreshRunner.submit(DrawerRefreshIntent.ListOnly) } diff --git a/src/daybook_compose/composeApp/src/commonMain/kotlin/org/example/daybook/capture/screens/CaptureScreen.kt b/src/daybook_compose/composeApp/src/commonMain/kotlin/org/example/daybook/capture/screens/CaptureScreen.kt index 1019efd0..8e14f9b9 100644 --- a/src/daybook_compose/composeApp/src/commonMain/kotlin/org/example/daybook/capture/screens/CaptureScreen.kt +++ b/src/daybook_compose/composeApp/src/commonMain/kotlin/org/example/daybook/capture/screens/CaptureScreen.kt @@ -46,14 +46,6 @@ import org.example.daybook.uniffi.core.* import org.example.daybook.uniffi.types.AddDocArgs import org.example.daybook.uniffi.types.Doc -sealed interface DocsListState { - data class Data(val docs: List) : DocsListState - - data class Error(val error: FfiException) : DocsListState - - object Loading : DocsListState -} - class CaptureScreenViewModel( val drawerRepo: DrawerRepoFfi, val tablesRepo: TablesRepoFfi, @@ -169,9 +161,6 @@ class CaptureScreenViewModel( } } - private val _docsList = MutableStateFlow(DocsListState.Loading as DocsListState) - val docsList = _docsList.asStateFlow() - // Registration handle to auto-unregister private var listenerRegistration: ListenerRegistration? = null @@ -181,20 +170,20 @@ class CaptureScreenViewModel( override fun onDrawerEvent(event: DrawerEvent) { viewModelScope.launch { when (event) { - is DrawerEvent.ListChanged -> { - refreshDocs() - } - - is DrawerEvent.DocAdded -> { - refreshDocs() - } - is DrawerEvent.DocUpdated -> { if (event.id == _currentDocId.value) { loadDoc(event.id) } } + is DrawerEvent.DocDeleted -> { + if (event.id == _currentDocId.value) { + _currentDocId.value = null + _currentDoc.value = null + editorController.bindDoc(null) + } + } + else -> {} } } @@ -202,7 +191,6 @@ class CaptureScreenViewModel( } init { - loadLatestDocs() if (initialDocId != null) { loadDoc(initialDocId) } else { @@ -234,30 +222,6 @@ class CaptureScreenViewModel( } } - private suspend fun refreshDocs() { - _docsList.value = DocsListState.Loading - try { - val branches = drawerRepo.list() - val docs = - branches.mapNotNull { b -> - try { - drawerRepo.get(b.docId, "main") - } catch (e: FfiException) { - null - } - } - _docsList.value = DocsListState.Data(docs) - } catch (err: FfiException) { - _docsList.value = DocsListState.Error(err) - } - } - - fun loadLatestDocs() { - viewModelScope.launch { - refreshDocs() - } - } - override fun onCleared() { listenerRegistration?.unregister() super.onCleared() diff --git a/src/daybook_compose/composeApp/src/commonMain/kotlin/org/example/daybook/drawer/DrawerScreen.kt b/src/daybook_compose/composeApp/src/commonMain/kotlin/org/example/daybook/drawer/DrawerScreen.kt index 0768ba3a..dced3cd7 100644 --- a/src/daybook_compose/composeApp/src/commonMain/kotlin/org/example/daybook/drawer/DrawerScreen.kt +++ b/src/daybook_compose/composeApp/src/commonMain/kotlin/org/example/daybook/drawer/DrawerScreen.kt @@ -118,10 +118,13 @@ class DrawerScreenViewModel( } @Composable -fun DrawerScreen(contentType: DaybookContentType, modifier: Modifier = Modifier) { +fun DrawerScreen( + drawerVm: DrawerViewModel, + contentType: DaybookContentType, + modifier: Modifier = Modifier +) { val container = LocalContainer.current val tablesVm: TablesViewModel = viewModel { TablesViewModel(container.tablesRepo) } - val drawerVm: DrawerViewModel = viewModel { DrawerViewModel(container.drawerRepo) } val vm = viewModel { DrawerScreenViewModel(drawerVm, container.tablesRepo, container.blobsRepo, tablesVm) diff --git a/src/daybook_compose/composeApp/src/commonMain/kotlin/org/example/daybook/progress/ProgressScreen.kt b/src/daybook_compose/composeApp/src/commonMain/kotlin/org/example/daybook/progress/ProgressScreen.kt index 0f67edf7..990ba617 100644 --- a/src/daybook_compose/composeApp/src/commonMain/kotlin/org/example/daybook/progress/ProgressScreen.kt +++ b/src/daybook_compose/composeApp/src/commonMain/kotlin/org/example/daybook/progress/ProgressScreen.kt @@ -18,6 +18,7 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.CheckCircle @@ -40,6 +41,7 @@ import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf @@ -128,6 +130,7 @@ fun ProgressList(modifier: Modifier = Modifier) { } if (selectedTask != null) { ProgressDetailScreen( + modifier = Modifier.fillMaxWidth().weight(1f), task = selectedTask, updates = data.selectedTaskUpdates, onBack = { vm.selectTask(null) } @@ -319,108 +322,158 @@ private fun TimelineUpdateRow(at: Long, deets: ProgressUpdateDeets) { @Composable private fun ProgressDetailScreen( + modifier: Modifier = Modifier, task: ProgressTask, updates: List, onBack: () -> Unit ) { val amountEntry = latestAmountEntry(task, updates) - val timelineUpdates = updates.filter { it.update.deets !is ProgressUpdateDeets.Amount } + val timelineUpdates = + updates + .asSequence() + .filter { it.update.deets !is ProgressUpdateDeets.Amount } + .sortedByDescending { it.sequence } + .toList() + val listState = rememberLazyListState() + val timelineHeaderIndex = + 2 + (if (task.tags.isNotEmpty()) 1 else 0) + (if (amountEntry != null) 1 else 0) + val showPinnedTimelineHeader by remember(listState, timelineHeaderIndex) { + derivedStateOf { + listState.firstVisibleItemIndex > timelineHeaderIndex || + (listState.firstVisibleItemIndex == timelineHeaderIndex && listState.firstVisibleItemScrollOffset > 0) + } + } ElevatedCard( - modifier = Modifier.fillMaxWidth(), + modifier = modifier.fillMaxSize(), shape = RoundedCornerShape(12.dp) ) { - Column( - modifier = Modifier - .fillMaxWidth() - .padding(12.dp), - verticalArrangement = Arrangement.spacedBy(8.dp) - ) { - val typeInfo = progressTypeInfo(task) - Row(verticalAlignment = Alignment.CenterVertically) { - Icon(typeInfo.icon, contentDescription = null, tint = MaterialTheme.colorScheme.primary) - Spacer(Modifier.size(8.dp)) - Column(Modifier.weight(1f)) { - Text( - typeInfo.label, - style = MaterialTheme.typography.titleMedium, - maxLines = 1, - overflow = TextOverflow.Ellipsis - ) + Box(modifier = Modifier.fillMaxSize()) { + LazyColumn( + state = listState, + modifier = Modifier + .fillMaxSize() + .padding(12.dp), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + val typeInfo = progressTypeInfo(task) + item(key = "header-main") { Row(verticalAlignment = Alignment.CenterVertically) { - LiveDurationText( - createdAtSecs = task.createdAt.epochSeconds, - endAtSecs = taskEndTimestamp(task), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - Spacer(Modifier.size(6.dp)) + Icon(typeInfo.icon, contentDescription = null, tint = MaterialTheme.colorScheme.primary) + Spacer(Modifier.size(8.dp)) + Column(Modifier.weight(1f)) { + Text( + typeInfo.label, + style = MaterialTheme.typography.titleMedium, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + Row(verticalAlignment = Alignment.CenterVertically) { + LiveDurationText( + createdAtSecs = task.createdAt.epochSeconds, + endAtSecs = taskEndTimestamp(task), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + Spacer(Modifier.size(6.dp)) + Text( + task.title ?: task.id, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } + } + IconButton(onClick = onBack) { + Icon(Icons.Default.Close, contentDescription = "Back to list") + } + } + } + + item(key = "header-state") { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically + ) { + TaskStateIcon(task.state) + Spacer(Modifier.size(8.dp)) Text( - task.title ?: task.id, - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - maxLines = 1, - overflow = TextOverflow.Ellipsis + task.state.name.lowercase() + .replaceFirstChar { it.uppercase() }, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant ) } } - IconButton(onClick = onBack) { - Icon(Icons.Default.Close, contentDescription = "Back to list") - } - } - Row( - modifier = Modifier.fillMaxWidth(), - verticalAlignment = Alignment.CenterVertically - ) { - TaskStateIcon(task.state) - Spacer(Modifier.size(8.dp)) - Text( - task.state.name.lowercase() - .replaceFirstChar { it.uppercase() }, - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - } - if (task.tags.isNotEmpty()) { - Column( - modifier = Modifier.fillMaxWidth(), - verticalArrangement = Arrangement.spacedBy(6.dp) - ) { - task.tags.forEach { tag -> - Surface( - shape = RoundedCornerShape(100.dp), - color = MaterialTheme.colorScheme.secondaryContainer + + if (task.tags.isNotEmpty()) { + item(key = "header-tags") { + Column( + modifier = Modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(6.dp) ) { + task.tags.forEach { tag -> + Surface( + shape = RoundedCornerShape(100.dp), + color = MaterialTheme.colorScheme.secondaryContainer + ) { + Text( + text = tag, + style = MaterialTheme.typography.labelSmall, + modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp) + ) + } + } + } + } + } + + if (amountEntry != null) { + item(key = "header-progress") { + Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { + Text("Progress", style = MaterialTheme.typography.titleSmall) + val amount = amountEntry.update.deets as ProgressUpdateDeets.Amount + ProgressAmountBlock(amount, modifier = Modifier.fillMaxWidth()) Text( - text = tag, + "Updated ${formatClock(amountEntry.at.epochSeconds)}", style = MaterialTheme.typography.labelSmall, - modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp) + color = MaterialTheme.colorScheme.onSurfaceVariant ) } } } + + item(key = "timeline-header") { + Text("Timeline", style = MaterialTheme.typography.titleSmall) + Spacer(Modifier.height(6.dp)) + } + + if (timelineUpdates.isEmpty()) { + item(key = "timeline-empty") { + Text( + "No timeline updates", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } else { + items(timelineUpdates, key = { it.sequence }) { update -> + TimelineUpdateRow(at = update.at.epochSeconds, deets = update.update.deets) + } + } } - if (amountEntry != null) { - Spacer(Modifier.height(4.dp)) - Text("Progress", style = MaterialTheme.typography.titleSmall) - Spacer(Modifier.height(4.dp)) - val amount = amountEntry.update.deets as ProgressUpdateDeets.Amount - ProgressAmountBlock(amount, modifier = Modifier.fillMaxWidth()) - Text( - "Updated ${formatClock(amountEntry.at.epochSeconds)}", - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - } - Spacer(Modifier.height(4.dp)) - Text("Timeline", style = MaterialTheme.typography.titleSmall) - Spacer(Modifier.height(4.dp)) - LazyColumn( - modifier = Modifier.fillMaxWidth().heightIn(max = 220.dp), - verticalArrangement = Arrangement.spacedBy(6.dp) - ) { - items(timelineUpdates, key = { it.sequence }) { update -> - TimelineUpdateRow(at = update.at.epochSeconds, deets = update.update.deets) + + if (showPinnedTimelineHeader) { + Surface( + color = MaterialTheme.colorScheme.surface, + modifier = Modifier.fillMaxWidth().align(Alignment.TopStart) + ) { + Column(modifier = Modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 12.dp)) { + Text("Timeline", style = MaterialTheme.typography.titleSmall) + Spacer(Modifier.height(6.dp)) + HorizontalDivider() + } } } } diff --git a/src/daybook_compose/composeApp/src/commonMain/kotlin/org/example/daybook/ui/facets.kt b/src/daybook_compose/composeApp/src/commonMain/kotlin/org/example/daybook/ui/facets.kt index 270547f9..4c72453d 100644 --- a/src/daybook_compose/composeApp/src/commonMain/kotlin/org/example/daybook/ui/facets.kt +++ b/src/daybook_compose/composeApp/src/commonMain/kotlin/org/example/daybook/ui/facets.kt @@ -34,7 +34,6 @@ fun encodeWellKnownFacet(facet: WellKnownFacet): String = is WellKnownFacet.Dmeta -> facetJsonCodec.encodeToString(facet.v1) is WellKnownFacet.RefGeneric -> facetJsonCodec.encodeToString(facet.v1) is WellKnownFacet.LabelGeneric -> facetJsonCodec.encodeToString(facet.v1) - is WellKnownFacet.PseudoLabel -> facetJsonCodec.encodeToString(facet.v1) is WellKnownFacet.TitleGeneric -> facetJsonCodec.encodeToString(facet.v1) is WellKnownFacet.PathGeneric -> facetJsonCodec.encodeToString(facet.v1) is WellKnownFacet.Pending -> facetJsonCodec.encodeToString(facet.v1) @@ -44,7 +43,6 @@ fun encodeWellKnownFacet(facet: WellKnownFacet): String = is WellKnownFacet.ImageMetadata -> facetJsonCodec.encodeToString(facet.v1) is WellKnownFacet.OcrResult -> facetJsonCodec.encodeToString(facet.v1) is WellKnownFacet.Embedding -> facetJsonCodec.encodeToString(facet.v1) - is WellKnownFacet.PseudoLabelCandidates -> facetJsonCodec.encodeToString(facet.v1) } @Suppress("UNCHECKED_CAST") @@ -58,8 +56,6 @@ inline fun decodeWellKnownFacet(value: String): Res WellKnownFacet.RefGeneric(facetJsonCodec.decodeFromString(value)) WellKnownFacet.LabelGeneric::class -> WellKnownFacet.LabelGeneric(facetJsonCodec.decodeFromString(value)) - WellKnownFacet.PseudoLabel::class -> - WellKnownFacet.PseudoLabel(facetJsonCodec.decodeFromString(value)) WellKnownFacet.TitleGeneric::class -> WellKnownFacet.TitleGeneric(facetJsonCodec.decodeFromString(value)) WellKnownFacet.PathGeneric::class -> @@ -78,8 +74,6 @@ inline fun decodeWellKnownFacet(value: String): Res WellKnownFacet.OcrResult(facetJsonCodec.decodeFromString(value)) WellKnownFacet.Embedding::class -> WellKnownFacet.Embedding(facetJsonCodec.decodeFromString(value)) - WellKnownFacet.PseudoLabelCandidates::class -> - WellKnownFacet.PseudoLabelCandidates(facetJsonCodec.decodeFromString(value)) else -> error("Unsupported WellKnownFacet type: ${T::class.qualifiedName}") } facetValue as T @@ -106,7 +100,6 @@ fun buildSelfFacetRefUrl(key: FacetKey): String { org.example.daybook.uniffi.types.WellKnownFacetTag.DMETA -> "org.example.daybook.dmeta" org.example.daybook.uniffi.types.WellKnownFacetTag.REF_GENERIC -> "org.example.daybook.refgeneric" org.example.daybook.uniffi.types.WellKnownFacetTag.LABEL_GENERIC -> "org.example.daybook.labelgeneric" - org.example.daybook.uniffi.types.WellKnownFacetTag.PSEUDO_LABEL -> "org.example.daybook.pseudolabel" org.example.daybook.uniffi.types.WellKnownFacetTag.TITLE_GENERIC -> "org.example.daybook.titlegeneric" org.example.daybook.uniffi.types.WellKnownFacetTag.PATH_GENERIC -> "org.example.daybook.pathgeneric" org.example.daybook.uniffi.types.WellKnownFacetTag.PENDING -> "org.example.daybook.pending" @@ -116,7 +109,6 @@ fun buildSelfFacetRefUrl(key: FacetKey): String { org.example.daybook.uniffi.types.WellKnownFacetTag.IMAGE_METADATA -> "org.example.daybook.imagemetadata" org.example.daybook.uniffi.types.WellKnownFacetTag.OCR_RESULT -> "org.example.daybook.ocrresult" org.example.daybook.uniffi.types.WellKnownFacetTag.EMBEDDING -> "org.example.daybook.embedding" - org.example.daybook.uniffi.types.WellKnownFacetTag.PSEUDO_LABEL_CANDIDATES -> "org.example.daybook.pseudolabelcandidates" } is FacetTag.Any -> tag.v1 } diff --git a/src/daybook_compose/composeApp/src/commonMain/kotlin/org/example/daybook/uniffi/core/daybook_core.kt b/src/daybook_compose/composeApp/src/commonMain/kotlin/org/example/daybook/uniffi/core/daybook_core.kt index cfd7baa1..0e882c7d 100644 --- a/src/daybook_compose/composeApp/src/commonMain/kotlin/org/example/daybook/uniffi/core/daybook_core.kt +++ b/src/daybook_compose/composeApp/src/commonMain/kotlin/org/example/daybook/uniffi/core/daybook_core.kt @@ -1373,6 +1373,87 @@ public object FfiConverterTypeListenerRegistration: FfiConverter { + override fun read(buf: ByteBuffer): BranchDeleteTombstone { + return BranchDeleteTombstone( + FfiConverterTypeVersionTag.read(buf), + FfiConverterString.read(buf), + FfiConverterTypeChangeHashSet.read(buf), + ) + } + + override fun allocationSize(value: BranchDeleteTombstone) = ( + FfiConverterTypeVersionTag.allocationSize(value.`vtag`) + + FfiConverterString.allocationSize(value.`branchDocId`) + + FfiConverterTypeChangeHashSet.allocationSize(value.`branchHeads`) + ) + + override fun write(value: BranchDeleteTombstone, buf: ByteBuffer) { + FfiConverterTypeVersionTag.write(value.`vtag`, buf) + FfiConverterString.write(value.`branchDocId`, buf) + FfiConverterTypeChangeHashSet.write(value.`branchHeads`, buf) + } +} + + + +data class BranchSnapshot ( + var `branchDocId`: kotlin.String + , + var `branchHeads`: ChangeHashSet + +){ + + + + + + companion object +} + +/** + * @suppress + */ +public object FfiConverterTypeBranchSnapshot: FfiConverterRustBuffer { + override fun read(buf: ByteBuffer): BranchSnapshot { + return BranchSnapshot( + FfiConverterString.read(buf), + FfiConverterTypeChangeHashSet.read(buf), + ) + } + + override fun allocationSize(value: BranchSnapshot) = ( + FfiConverterString.allocationSize(value.`branchDocId`) + + FfiConverterTypeChangeHashSet.allocationSize(value.`branchHeads`) + ) + + override fun write(value: BranchSnapshot, buf: ByteBuffer) { + FfiConverterString.write(value.`branchDocId`, buf) + FfiConverterTypeChangeHashSet.write(value.`branchHeads`, buf) + } +} + + + data class CreateProgressTaskArgs ( var `id`: kotlin.String , @@ -1464,9 +1545,49 @@ public object FfiConverterTypeDocBundle: FfiConverterRustBuffer { +data class DocDeleteTombstone ( + var `vtag`: VersionTag + , + var `branches`: Map + +){ + + + + + + companion object +} + +/** + * @suppress + */ +public object FfiConverterTypeDocDeleteTombstone: FfiConverterRustBuffer { + override fun read(buf: ByteBuffer): DocDeleteTombstone { + return DocDeleteTombstone( + FfiConverterTypeVersionTag.read(buf), + FfiConverterMapStringTypeBranchSnapshot.read(buf), + ) + } + + override fun allocationSize(value: DocDeleteTombstone) = ( + FfiConverterTypeVersionTag.allocationSize(value.`vtag`) + + FfiConverterMapStringTypeBranchSnapshot.allocationSize(value.`branches`) + ) + + override fun write(value: DocDeleteTombstone, buf: ByteBuffer) { + FfiConverterTypeVersionTag.write(value.`vtag`, buf) + FfiConverterMapStringTypeBranchSnapshot.write(value.`branches`, buf) + } +} + + + data class DocEntry ( var `branches`: Map , + var `branchesDeleted`: Map> + , var `vtag`: VersionTag , var `previousVersionHeads`: ChangeHashSet? @@ -1487,6 +1608,7 @@ public object FfiConverterTypeDocEntry: FfiConverterRustBuffer { override fun read(buf: ByteBuffer): DocEntry { return DocEntry( FfiConverterMapStringTypeStoredBranchRef.read(buf), + FfiConverterMapStringSequenceTypeBranchDeleteTombstone.read(buf), FfiConverterTypeVersionTag.read(buf), FfiConverterOptionalTypeChangeHashSet.read(buf), ) @@ -1494,12 +1616,14 @@ public object FfiConverterTypeDocEntry: FfiConverterRustBuffer { override fun allocationSize(value: DocEntry) = ( FfiConverterMapStringTypeStoredBranchRef.allocationSize(value.`branches`) + + FfiConverterMapStringSequenceTypeBranchDeleteTombstone.allocationSize(value.`branchesDeleted`) + FfiConverterTypeVersionTag.allocationSize(value.`vtag`) + FfiConverterOptionalTypeChangeHashSet.allocationSize(value.`previousVersionHeads`) ) override fun write(value: DocEntry, buf: ByteBuffer) { FfiConverterMapStringTypeStoredBranchRef.write(value.`branches`, buf) + FfiConverterMapStringSequenceTypeBranchDeleteTombstone.write(value.`branchesDeleted`, buf) FfiConverterTypeVersionTag.write(value.`vtag`, buf) FfiConverterOptionalTypeChangeHashSet.write(value.`previousVersionHeads`, buf) } @@ -1510,6 +1634,10 @@ public object FfiConverterTypeDocEntry: FfiConverterRustBuffer { data class DocEntryDiff ( var `changedFacetKeys`: List , + var `addedFacetKeys`: List + , + var `removedFacetKeys`: List + , var `movedBranchNames`: List ){ @@ -1527,6 +1655,8 @@ data class DocEntryDiff ( public object FfiConverterTypeDocEntryDiff: FfiConverterRustBuffer { override fun read(buf: ByteBuffer): DocEntryDiff { return DocEntryDiff( + FfiConverterSequenceTypeFacetKey.read(buf), + FfiConverterSequenceTypeFacetKey.read(buf), FfiConverterSequenceTypeFacetKey.read(buf), FfiConverterSequenceString.read(buf), ) @@ -1534,11 +1664,15 @@ public object FfiConverterTypeDocEntryDiff: FfiConverterRustBuffer override fun allocationSize(value: DocEntryDiff) = ( FfiConverterSequenceTypeFacetKey.allocationSize(value.`changedFacetKeys`) + + FfiConverterSequenceTypeFacetKey.allocationSize(value.`addedFacetKeys`) + + FfiConverterSequenceTypeFacetKey.allocationSize(value.`removedFacetKeys`) + FfiConverterSequenceString.allocationSize(value.`movedBranchNames`) ) override fun write(value: DocEntryDiff, buf: ByteBuffer) { FfiConverterSequenceTypeFacetKey.write(value.`changedFacetKeys`, buf) + FfiConverterSequenceTypeFacetKey.write(value.`addedFacetKeys`, buf) + FfiConverterSequenceTypeFacetKey.write(value.`removedFacetKeys`, buf) FfiConverterSequenceString.write(value.`movedBranchNames`, buf) } } @@ -1583,49 +1717,6 @@ public object FfiConverterTypeDocNBranches: FfiConverterRustBuffer -data class FacetDisplayHint ( - var `alwaysVisible`: kotlin.Boolean - , - var `displayTitle`: kotlin.String? - , - var `deets`: FacetKeyDisplayDeets - -){ - - - - - - companion object -} - -/** - * @suppress - */ -public object FfiConverterTypeFacetDisplayHint: FfiConverterRustBuffer { - override fun read(buf: ByteBuffer): FacetDisplayHint { - return FacetDisplayHint( - FfiConverterBoolean.read(buf), - FfiConverterOptionalString.read(buf), - FfiConverterTypeFacetKeyDisplayDeets.read(buf), - ) - } - - override fun allocationSize(value: FacetDisplayHint) = ( - FfiConverterBoolean.allocationSize(value.`alwaysVisible`) + - FfiConverterOptionalString.allocationSize(value.`displayTitle`) + - FfiConverterTypeFacetKeyDisplayDeets.allocationSize(value.`deets`) - ) - - override fun write(value: FacetDisplayHint, buf: ByteBuffer) { - FfiConverterBoolean.write(value.`alwaysVisible`, buf) - FfiConverterOptionalString.write(value.`displayTitle`, buf) - FfiConverterTypeFacetKeyDisplayDeets.write(value.`deets`, buf) - } -} - - - data class KnownRepoEntry ( var `id`: kotlin.String , @@ -2718,7 +2809,8 @@ public object FfiConverterTypeCaptureMode: FfiConverterRustBuffer { sealed class ConfigEvent { data class Changed( - val `heads`: org.example.daybook.uniffi.core.ChangeHashSet) : ConfigEvent() + val `heads`: org.example.daybook.uniffi.core.ChangeHashSet, + val `origin`: org.example.daybook.uniffi.core.SwitchEventOrigin) : ConfigEvent() { @@ -2726,8 +2818,14 @@ sealed class ConfigEvent { companion object } - object SyncDevicesChanged : ConfigEvent() - + data class SyncDevicesChanged( + val `origin`: org.example.daybook.uniffi.core.SwitchEventOrigin) : ConfigEvent() + + { + + + companion object + } @@ -2747,8 +2845,11 @@ public object FfiConverterTypeConfigEvent : FfiConverterRustBuffer{ return when(buf.getInt()) { 1 -> ConfigEvent.Changed( FfiConverterTypeChangeHashSet.read(buf), + FfiConverterTypeSwitchEventOrigin.read(buf), + ) + 2 -> ConfigEvent.SyncDevicesChanged( + FfiConverterTypeSwitchEventOrigin.read(buf), ) - 2 -> ConfigEvent.SyncDevicesChanged else -> throw RuntimeException("invalid enum value, something is very wrong!!") } } @@ -2759,12 +2860,14 @@ public object FfiConverterTypeConfigEvent : FfiConverterRustBuffer{ ( 4UL + FfiConverterTypeChangeHashSet.allocationSize(value.`heads`) + + FfiConverterTypeSwitchEventOrigin.allocationSize(value.`origin`) ) } is ConfigEvent.SyncDevicesChanged -> { // Add the size for the Int that specifies the variant plus the size needed for all fields ( 4UL + + FfiConverterTypeSwitchEventOrigin.allocationSize(value.`origin`) ) } } @@ -2774,10 +2877,12 @@ public object FfiConverterTypeConfigEvent : FfiConverterRustBuffer{ is ConfigEvent.Changed -> { buf.putInt(1) FfiConverterTypeChangeHashSet.write(value.`heads`, buf) + FfiConverterTypeSwitchEventOrigin.write(value.`origin`, buf) Unit } is ConfigEvent.SyncDevicesChanged -> { buf.putInt(2) + FfiConverterTypeSwitchEventOrigin.write(value.`origin`, buf) Unit } }.let { /* this makes the `when` an expression, which ensures it is exhaustive */ } @@ -2788,47 +2893,23 @@ public object FfiConverterTypeConfigEvent : FfiConverterRustBuffer{ - -enum class DateTimeFacetDisplayType { - - TIME_AND_DATE, - RELATIVE, - TIME_ONLY, - DATE_ONLY; - +sealed class DispatchEvent { + data class DispatchAdded( + val `id`: kotlin.String, + val `heads`: org.example.daybook.uniffi.core.ChangeHashSet, + val `origin`: org.example.daybook.uniffi.core.SwitchEventOrigin) : DispatchEvent() + + { + - - companion object -} - - -/** - * @suppress - */ -public object FfiConverterTypeDateTimeFacetDisplayType: FfiConverterRustBuffer { - override fun read(buf: ByteBuffer) = try { - DateTimeFacetDisplayType.values()[buf.getInt() - 1] - } catch (e: IndexOutOfBoundsException) { - throw RuntimeException("invalid enum value, something is very wrong!!", e) - } - - override fun allocationSize(value: DateTimeFacetDisplayType) = 4UL - - override fun write(value: DateTimeFacetDisplayType, buf: ByteBuffer) { - buf.putInt(value.ordinal + 1) + companion object } -} - - - - - -sealed class DispatchEvent { - data class DispatchAdded( + data class DispatchUpdated( val `id`: kotlin.String, - val `heads`: org.example.daybook.uniffi.core.ChangeHashSet) : DispatchEvent() + val `heads`: org.example.daybook.uniffi.core.ChangeHashSet, + val `origin`: org.example.daybook.uniffi.core.SwitchEventOrigin) : DispatchEvent() { @@ -2838,7 +2919,8 @@ sealed class DispatchEvent { data class DispatchDeleted( val `id`: kotlin.String, - val `heads`: org.example.daybook.uniffi.core.ChangeHashSet) : DispatchEvent() + val `heads`: org.example.daybook.uniffi.core.ChangeHashSet, + val `origin`: org.example.daybook.uniffi.core.SwitchEventOrigin) : DispatchEvent() { @@ -2865,10 +2947,17 @@ public object FfiConverterTypeDispatchEvent : FfiConverterRustBuffer DispatchEvent.DispatchAdded( FfiConverterString.read(buf), FfiConverterTypeChangeHashSet.read(buf), + FfiConverterTypeSwitchEventOrigin.read(buf), ) - 2 -> DispatchEvent.DispatchDeleted( + 2 -> DispatchEvent.DispatchUpdated( FfiConverterString.read(buf), FfiConverterTypeChangeHashSet.read(buf), + FfiConverterTypeSwitchEventOrigin.read(buf), + ) + 3 -> DispatchEvent.DispatchDeleted( + FfiConverterString.read(buf), + FfiConverterTypeChangeHashSet.read(buf), + FfiConverterTypeSwitchEventOrigin.read(buf), ) else -> throw RuntimeException("invalid enum value, something is very wrong!!") } @@ -2881,6 +2970,16 @@ public object FfiConverterTypeDispatchEvent : FfiConverterRustBuffer { + // Add the size for the Int that specifies the variant plus the size needed for all fields + ( + 4UL + + FfiConverterString.allocationSize(value.`id`) + + FfiConverterTypeChangeHashSet.allocationSize(value.`heads`) + + FfiConverterTypeSwitchEventOrigin.allocationSize(value.`origin`) ) } is DispatchEvent.DispatchDeleted -> { @@ -2889,6 +2988,7 @@ public object FfiConverterTypeDispatchEvent : FfiConverterRustBuffer { + is DispatchEvent.DispatchUpdated -> { buf.putInt(2) FfiConverterString.write(value.`id`, buf) FfiConverterTypeChangeHashSet.write(value.`heads`, buf) + FfiConverterTypeSwitchEventOrigin.write(value.`origin`, buf) + Unit + } + is DispatchEvent.DispatchDeleted -> { + buf.putInt(3) + FfiConverterString.write(value.`id`, buf) + FfiConverterTypeChangeHashSet.write(value.`heads`, buf) + FfiConverterTypeSwitchEventOrigin.write(value.`origin`, buf) Unit } }.let { /* this makes the `when` an expression, which ensures it is exhaustive */ } @@ -2917,19 +3026,11 @@ public object FfiConverterTypeDispatchEvent : FfiConverterRustBuffer, + val `entry`: org.example.daybook.uniffi.core.DocEntry?, + val `origin`: org.example.daybook.uniffi.core.SwitchEventOrigin) : DrawerEvent() { @@ -2976,37 +3080,31 @@ sealed class DrawerEvent { public object FfiConverterTypeDrawerEvent : FfiConverterRustBuffer{ override fun read(buf: ByteBuffer): DrawerEvent { return when(buf.getInt()) { - 1 -> DrawerEvent.ListChanged( - FfiConverterTypeChangeHashSet.read(buf), - ) - 2 -> DrawerEvent.DocAdded( + 1 -> DrawerEvent.DocAdded( FfiConverterString.read(buf), FfiConverterTypeDocNBranches.read(buf), FfiConverterTypeChangeHashSet.read(buf), + FfiConverterTypeSwitchEventOrigin.read(buf), ) - 3 -> DrawerEvent.DocUpdated( + 2 -> DrawerEvent.DocUpdated( FfiConverterString.read(buf), FfiConverterTypeDocNBranches.read(buf), FfiConverterTypeDocEntryDiff.read(buf), FfiConverterTypeChangeHashSet.read(buf), + FfiConverterTypeSwitchEventOrigin.read(buf), ) - 4 -> DrawerEvent.DocDeleted( + 3 -> DrawerEvent.DocDeleted( FfiConverterString.read(buf), FfiConverterTypeChangeHashSet.read(buf), + FfiConverterSequenceTypeFacetKey.read(buf), FfiConverterOptionalTypeDocEntry.read(buf), + FfiConverterTypeSwitchEventOrigin.read(buf), ) else -> throw RuntimeException("invalid enum value, something is very wrong!!") } } override fun allocationSize(value: DrawerEvent) = when(value) { - is DrawerEvent.ListChanged -> { - // Add the size for the Int that specifies the variant plus the size needed for all fields - ( - 4UL - + FfiConverterTypeChangeHashSet.allocationSize(value.`drawerHeads`) - ) - } is DrawerEvent.DocAdded -> { // Add the size for the Int that specifies the variant plus the size needed for all fields ( @@ -3014,6 +3112,7 @@ public object FfiConverterTypeDrawerEvent : FfiConverterRustBuffer{ + FfiConverterString.allocationSize(value.`id`) + FfiConverterTypeDocNBranches.allocationSize(value.`entry`) + FfiConverterTypeChangeHashSet.allocationSize(value.`drawerHeads`) + + FfiConverterTypeSwitchEventOrigin.allocationSize(value.`origin`) ) } is DrawerEvent.DocUpdated -> { @@ -3024,6 +3123,7 @@ public object FfiConverterTypeDrawerEvent : FfiConverterRustBuffer{ + FfiConverterTypeDocNBranches.allocationSize(value.`entry`) + FfiConverterTypeDocEntryDiff.allocationSize(value.`diff`) + FfiConverterTypeChangeHashSet.allocationSize(value.`drawerHeads`) + + FfiConverterTypeSwitchEventOrigin.allocationSize(value.`origin`) ) } is DrawerEvent.DocDeleted -> { @@ -3032,38 +3132,39 @@ public object FfiConverterTypeDrawerEvent : FfiConverterRustBuffer{ 4UL + FfiConverterString.allocationSize(value.`id`) + FfiConverterTypeChangeHashSet.allocationSize(value.`drawerHeads`) + + FfiConverterSequenceTypeFacetKey.allocationSize(value.`deletedFacetKeys`) + FfiConverterOptionalTypeDocEntry.allocationSize(value.`entry`) + + FfiConverterTypeSwitchEventOrigin.allocationSize(value.`origin`) ) } } override fun write(value: DrawerEvent, buf: ByteBuffer) { when(value) { - is DrawerEvent.ListChanged -> { - buf.putInt(1) - FfiConverterTypeChangeHashSet.write(value.`drawerHeads`, buf) - Unit - } is DrawerEvent.DocAdded -> { - buf.putInt(2) + buf.putInt(1) FfiConverterString.write(value.`id`, buf) FfiConverterTypeDocNBranches.write(value.`entry`, buf) FfiConverterTypeChangeHashSet.write(value.`drawerHeads`, buf) + FfiConverterTypeSwitchEventOrigin.write(value.`origin`, buf) Unit } is DrawerEvent.DocUpdated -> { - buf.putInt(3) + buf.putInt(2) FfiConverterString.write(value.`id`, buf) FfiConverterTypeDocNBranches.write(value.`entry`, buf) FfiConverterTypeDocEntryDiff.write(value.`diff`, buf) FfiConverterTypeChangeHashSet.write(value.`drawerHeads`, buf) + FfiConverterTypeSwitchEventOrigin.write(value.`origin`, buf) Unit } is DrawerEvent.DocDeleted -> { - buf.putInt(4) + buf.putInt(3) FfiConverterString.write(value.`id`, buf) FfiConverterTypeChangeHashSet.write(value.`drawerHeads`, buf) + FfiConverterSequenceTypeFacetKey.write(value.`deletedFacetKeys`, buf) FfiConverterOptionalTypeDocEntry.write(value.`entry`, buf) + FfiConverterTypeSwitchEventOrigin.write(value.`origin`, buf) Unit } }.let { /* this makes the `when` an expression, which ensures it is exhaustive */ } @@ -3074,25 +3175,12 @@ public object FfiConverterTypeDrawerEvent : FfiConverterRustBuffer{ -sealed class FacetKeyDisplayDeets { - - object DebugPrint : FacetKeyDisplayDeets() - - - data class DateTime( - val `displayType`: org.example.daybook.uniffi.core.DateTimeFacetDisplayType) : FacetKeyDisplayDeets() - - { - - - companion object - } - - object UnixPath : FacetKeyDisplayDeets() - +sealed class PlugsEvent { - data class Title( - val `showEditor`: kotlin.Boolean) : FacetKeyDisplayDeets() + data class PlugAdded( + val `id`: kotlin.String, + val `heads`: org.example.daybook.uniffi.core.ChangeHashSet, + val `origin`: org.example.daybook.uniffi.core.SwitchEventOrigin) : PlugsEvent() { @@ -3100,96 +3188,10 @@ sealed class FacetKeyDisplayDeets { companion object } - - - - - - - - companion object -} - -/** - * @suppress - */ -public object FfiConverterTypeFacetKeyDisplayDeets : FfiConverterRustBuffer{ - override fun read(buf: ByteBuffer): FacetKeyDisplayDeets { - return when(buf.getInt()) { - 1 -> FacetKeyDisplayDeets.DebugPrint - 2 -> FacetKeyDisplayDeets.DateTime( - FfiConverterTypeDateTimeFacetDisplayType.read(buf), - ) - 3 -> FacetKeyDisplayDeets.UnixPath - 4 -> FacetKeyDisplayDeets.Title( - FfiConverterBoolean.read(buf), - ) - else -> throw RuntimeException("invalid enum value, something is very wrong!!") - } - } - - override fun allocationSize(value: FacetKeyDisplayDeets) = when(value) { - is FacetKeyDisplayDeets.DebugPrint -> { - // Add the size for the Int that specifies the variant plus the size needed for all fields - ( - 4UL - ) - } - is FacetKeyDisplayDeets.DateTime -> { - // Add the size for the Int that specifies the variant plus the size needed for all fields - ( - 4UL - + FfiConverterTypeDateTimeFacetDisplayType.allocationSize(value.`displayType`) - ) - } - is FacetKeyDisplayDeets.UnixPath -> { - // Add the size for the Int that specifies the variant plus the size needed for all fields - ( - 4UL - ) - } - is FacetKeyDisplayDeets.Title -> { - // Add the size for the Int that specifies the variant plus the size needed for all fields - ( - 4UL - + FfiConverterBoolean.allocationSize(value.`showEditor`) - ) - } - } - - override fun write(value: FacetKeyDisplayDeets, buf: ByteBuffer) { - when(value) { - is FacetKeyDisplayDeets.DebugPrint -> { - buf.putInt(1) - Unit - } - is FacetKeyDisplayDeets.DateTime -> { - buf.putInt(2) - FfiConverterTypeDateTimeFacetDisplayType.write(value.`displayType`, buf) - Unit - } - is FacetKeyDisplayDeets.UnixPath -> { - buf.putInt(3) - Unit - } - is FacetKeyDisplayDeets.Title -> { - buf.putInt(4) - FfiConverterBoolean.write(value.`showEditor`, buf) - Unit - } - }.let { /* this makes the `when` an expression, which ensures it is exhaustive */ } - } -} - - - - - -sealed class PlugsEvent { - - data class PlugAdded( + data class PlugChanged( val `id`: kotlin.String, - val `heads`: org.example.daybook.uniffi.core.ChangeHashSet) : PlugsEvent() + val `heads`: org.example.daybook.uniffi.core.ChangeHashSet, + val `origin`: org.example.daybook.uniffi.core.SwitchEventOrigin) : PlugsEvent() { @@ -3197,9 +3199,10 @@ sealed class PlugsEvent { companion object } - data class PlugChanged( + data class PlugDeleted( val `id`: kotlin.String, - val `heads`: org.example.daybook.uniffi.core.ChangeHashSet) : PlugsEvent() + val `heads`: org.example.daybook.uniffi.core.ChangeHashSet, + val `origin`: org.example.daybook.uniffi.core.SwitchEventOrigin) : PlugsEvent() { @@ -3207,9 +3210,9 @@ sealed class PlugsEvent { companion object } - data class PlugDeleted( - val `id`: kotlin.String, - val `heads`: org.example.daybook.uniffi.core.ChangeHashSet) : PlugsEvent() + data class ConfigDocsChanged( + val `heads`: org.example.daybook.uniffi.core.ChangeHashSet, + val `origin`: org.example.daybook.uniffi.core.SwitchEventOrigin) : PlugsEvent() { @@ -3236,14 +3239,21 @@ public object FfiConverterTypePlugsEvent : FfiConverterRustBuffer{ 1 -> PlugsEvent.PlugAdded( FfiConverterString.read(buf), FfiConverterTypeChangeHashSet.read(buf), + FfiConverterTypeSwitchEventOrigin.read(buf), ) 2 -> PlugsEvent.PlugChanged( FfiConverterString.read(buf), FfiConverterTypeChangeHashSet.read(buf), + FfiConverterTypeSwitchEventOrigin.read(buf), ) 3 -> PlugsEvent.PlugDeleted( FfiConverterString.read(buf), FfiConverterTypeChangeHashSet.read(buf), + FfiConverterTypeSwitchEventOrigin.read(buf), + ) + 4 -> PlugsEvent.ConfigDocsChanged( + FfiConverterTypeChangeHashSet.read(buf), + FfiConverterTypeSwitchEventOrigin.read(buf), ) else -> throw RuntimeException("invalid enum value, something is very wrong!!") } @@ -3256,6 +3266,7 @@ public object FfiConverterTypePlugsEvent : FfiConverterRustBuffer{ 4UL + FfiConverterString.allocationSize(value.`id`) + FfiConverterTypeChangeHashSet.allocationSize(value.`heads`) + + FfiConverterTypeSwitchEventOrigin.allocationSize(value.`origin`) ) } is PlugsEvent.PlugChanged -> { @@ -3264,6 +3275,7 @@ public object FfiConverterTypePlugsEvent : FfiConverterRustBuffer{ 4UL + FfiConverterString.allocationSize(value.`id`) + FfiConverterTypeChangeHashSet.allocationSize(value.`heads`) + + FfiConverterTypeSwitchEventOrigin.allocationSize(value.`origin`) ) } is PlugsEvent.PlugDeleted -> { @@ -3272,6 +3284,15 @@ public object FfiConverterTypePlugsEvent : FfiConverterRustBuffer{ 4UL + FfiConverterString.allocationSize(value.`id`) + FfiConverterTypeChangeHashSet.allocationSize(value.`heads`) + + FfiConverterTypeSwitchEventOrigin.allocationSize(value.`origin`) + ) + } + is PlugsEvent.ConfigDocsChanged -> { + // Add the size for the Int that specifies the variant plus the size needed for all fields + ( + 4UL + + FfiConverterTypeChangeHashSet.allocationSize(value.`heads`) + + FfiConverterTypeSwitchEventOrigin.allocationSize(value.`origin`) ) } } @@ -3282,18 +3303,27 @@ public object FfiConverterTypePlugsEvent : FfiConverterRustBuffer{ buf.putInt(1) FfiConverterString.write(value.`id`, buf) FfiConverterTypeChangeHashSet.write(value.`heads`, buf) + FfiConverterTypeSwitchEventOrigin.write(value.`origin`, buf) Unit } is PlugsEvent.PlugChanged -> { buf.putInt(2) FfiConverterString.write(value.`id`, buf) FfiConverterTypeChangeHashSet.write(value.`heads`, buf) + FfiConverterTypeSwitchEventOrigin.write(value.`origin`, buf) Unit } is PlugsEvent.PlugDeleted -> { buf.putInt(3) FfiConverterString.write(value.`id`, buf) FfiConverterTypeChangeHashSet.write(value.`heads`, buf) + FfiConverterTypeSwitchEventOrigin.write(value.`origin`, buf) + Unit + } + is PlugsEvent.ConfigDocsChanged -> { + buf.putInt(4) + FfiConverterTypeChangeHashSet.write(value.`heads`, buf) + FfiConverterTypeSwitchEventOrigin.write(value.`origin`, buf) Unit } }.let { /* this makes the `when` an expression, which ensures it is exhaustive */ } @@ -3838,6 +3868,103 @@ public object FfiConverterTypeProgressUpdateDeets : FfiConverterRustBuffer{ + override fun read(buf: ByteBuffer): SwitchEventOrigin { + return when(buf.getInt()) { + 1 -> SwitchEventOrigin.Local( + FfiConverterString.read(buf), + ) + 2 -> SwitchEventOrigin.Remote( + FfiConverterString.read(buf), + ) + 3 -> SwitchEventOrigin.Bootstrap + else -> throw RuntimeException("invalid enum value, something is very wrong!!") + } + } + + override fun allocationSize(value: SwitchEventOrigin) = when(value) { + is SwitchEventOrigin.Local -> { + // Add the size for the Int that specifies the variant plus the size needed for all fields + ( + 4UL + + FfiConverterString.allocationSize(value.`actorId`) + ) + } + is SwitchEventOrigin.Remote -> { + // Add the size for the Int that specifies the variant plus the size needed for all fields + ( + 4UL + + FfiConverterString.allocationSize(value.`peerId`) + ) + } + is SwitchEventOrigin.Bootstrap -> { + // Add the size for the Int that specifies the variant plus the size needed for all fields + ( + 4UL + ) + } + } + + override fun write(value: SwitchEventOrigin, buf: ByteBuffer) { + when(value) { + is SwitchEventOrigin.Local -> { + buf.putInt(1) + FfiConverterString.write(value.`actorId`, buf) + Unit + } + is SwitchEventOrigin.Remote -> { + buf.putInt(2) + FfiConverterString.write(value.`peerId`, buf) + Unit + } + is SwitchEventOrigin.Bootstrap -> { + buf.putInt(3) + Unit + } + }.let { /* this makes the `when` an expression, which ensures it is exhaustive */ } + } +} + + + + + sealed class TableWindow { object AllWindows : TableWindow() @@ -5111,6 +5238,34 @@ public object FfiConverterSequenceString: FfiConverterRustBuffer> { + override fun read(buf: ByteBuffer): List { + val len = buf.getInt() + return List(len) { + FfiConverterTypeBranchDeleteTombstone.read(buf) + } + } + + override fun allocationSize(value: List): ULong { + val sizeForLength = 4UL + val sizeForItems = value.map { FfiConverterTypeBranchDeleteTombstone.allocationSize(it) }.sum() + return sizeForLength + sizeForItems + } + + override fun write(value: List, buf: ByteBuffer) { + buf.putInt(value.size) + value.iterator().forEach { + FfiConverterTypeBranchDeleteTombstone.write(it, buf) + } + } +} + + + + /** * @suppress */ @@ -5335,6 +5490,45 @@ public object FfiConverterSequenceTypeUuid: FfiConverterRustBuffer> { +/** + * @suppress + */ +public object FfiConverterMapStringTypeBranchSnapshot: FfiConverterRustBuffer> { + override fun read(buf: ByteBuffer): Map { + val len = buf.getInt() + return buildMap(len) { + repeat(len) { + val k = FfiConverterString.read(buf) + val v = FfiConverterTypeBranchSnapshot.read(buf) + this[k] = v + } + } + } + + override fun allocationSize(value: Map): ULong { + val spaceForMapSize = 4UL + val spaceForChildren = value.map { (k, v) -> + FfiConverterString.allocationSize(k) + + FfiConverterTypeBranchSnapshot.allocationSize(v) + }.sum() + return spaceForMapSize + spaceForChildren + } + + override fun write(value: Map, buf: ByteBuffer) { + buf.putInt(value.size) + // The parens on `(k, v)` here ensure we're calling the right method, + // which is important for compatibility with older android devices. + // Ref https://blog.danlew.net/2017/03/16/kotlin-puzzler-whose-line-is-it-anyways/ + value.forEach { (k, v) -> + FfiConverterString.write(k, buf) + FfiConverterTypeBranchSnapshot.write(v, buf) + } + } +} + + + + /** * @suppress */ @@ -5374,6 +5568,45 @@ public object FfiConverterMapStringTypeStoredBranchRef: FfiConverterRustBuffer>> { + override fun read(buf: ByteBuffer): Map> { + val len = buf.getInt() + return buildMap>(len) { + repeat(len) { + val k = FfiConverterString.read(buf) + val v = FfiConverterSequenceTypeBranchDeleteTombstone.read(buf) + this[k] = v + } + } + } + + override fun allocationSize(value: Map>): ULong { + val spaceForMapSize = 4UL + val spaceForChildren = value.map { (k, v) -> + FfiConverterString.allocationSize(k) + + FfiConverterSequenceTypeBranchDeleteTombstone.allocationSize(v) + }.sum() + return spaceForMapSize + spaceForChildren + } + + override fun write(value: Map>, buf: ByteBuffer) { + buf.putInt(value.size) + // The parens on `(k, v)` here ensure we're calling the right method, + // which is important for compatibility with older android devices. + // Ref https://blog.danlew.net/2017/03/16/kotlin-puzzler-whose-line-is-it-anyways/ + value.forEach { (k, v) -> + FfiConverterString.write(k, buf) + FfiConverterSequenceTypeBranchDeleteTombstone.write(v, buf) + } + } +} + + + + /** * @suppress */ @@ -5582,3 +5815,10 @@ public object FfiConverterTypeUuid: FfiConverter { */ public typealias VersionTag = kotlin.String public typealias FfiConverterTypeVersionTag = FfiConverterString + + + + + + + diff --git a/src/daybook_compose/composeApp/src/commonMain/kotlin/org/example/daybook/uniffi/daybook_ffi.kt b/src/daybook_compose/composeApp/src/commonMain/kotlin/org/example/daybook/uniffi/daybook_ffi.kt index 9adbe2f9..6dd8c03a 100644 --- a/src/daybook_compose/composeApp/src/commonMain/kotlin/org/example/daybook/uniffi/daybook_ffi.kt +++ b/src/daybook_compose/composeApp/src/commonMain/kotlin/org/example/daybook/uniffi/daybook_ffi.kt @@ -48,7 +48,6 @@ import org.example.daybook.uniffi.core.DocBundle import org.example.daybook.uniffi.core.DocEntry import org.example.daybook.uniffi.core.DocNBranches import org.example.daybook.uniffi.core.DrawerEvent -import org.example.daybook.uniffi.core.FacetDisplayHint import org.example.daybook.uniffi.core.FfiConverterTypeConfigEvent import org.example.daybook.uniffi.core.FfiConverterTypeCreateProgressTaskArgs import org.example.daybook.uniffi.core.FfiConverterTypeDispatchEvent @@ -56,7 +55,6 @@ import org.example.daybook.uniffi.core.FfiConverterTypeDocBundle import org.example.daybook.uniffi.core.FfiConverterTypeDocEntry import org.example.daybook.uniffi.core.FfiConverterTypeDocNBranches import org.example.daybook.uniffi.core.FfiConverterTypeDrawerEvent -import org.example.daybook.uniffi.core.FfiConverterTypeFacetDisplayHint import org.example.daybook.uniffi.core.FfiConverterTypeKnownRepoEntry import org.example.daybook.uniffi.core.FfiConverterTypeListenerRegistration import org.example.daybook.uniffi.core.FfiConverterTypePanel @@ -92,9 +90,11 @@ import org.example.daybook.uniffi.core.Window import org.example.daybook.uniffi.types.AddDocArgs import org.example.daybook.uniffi.types.Doc import org.example.daybook.uniffi.types.DocPatch +import org.example.daybook.uniffi.types.FacetDisplayHint import org.example.daybook.uniffi.types.FfiConverterTypeAddDocArgs import org.example.daybook.uniffi.types.FfiConverterTypeDoc import org.example.daybook.uniffi.types.FfiConverterTypeDocPatch +import org.example.daybook.uniffi.types.FfiConverterTypeFacetDisplayHint import org.example.daybook.uniffi.core.RustBuffer as RustBufferConfigEvent import org.example.daybook.uniffi.core.RustBuffer as RustBufferCreateProgressTaskArgs import org.example.daybook.uniffi.core.RustBuffer as RustBufferDispatchEvent @@ -102,7 +102,6 @@ import org.example.daybook.uniffi.core.RustBuffer as RustBufferDocBundle import org.example.daybook.uniffi.core.RustBuffer as RustBufferDocEntry import org.example.daybook.uniffi.core.RustBuffer as RustBufferDocNBranches import org.example.daybook.uniffi.core.RustBuffer as RustBufferDrawerEvent -import org.example.daybook.uniffi.core.RustBuffer as RustBufferFacetDisplayHint import org.example.daybook.uniffi.core.RustBuffer as RustBufferKnownRepoEntry import org.example.daybook.uniffi.core.RustBuffer as RustBufferListenerRegistration import org.example.daybook.uniffi.core.RustBuffer as RustBufferPanel @@ -122,6 +121,7 @@ import org.example.daybook.uniffi.core.RustBuffer as RustBufferWindow import org.example.daybook.uniffi.types.RustBuffer as RustBufferAddDocArgs import org.example.daybook.uniffi.types.RustBuffer as RustBufferDoc import org.example.daybook.uniffi.types.RustBuffer as RustBufferDocPatch +import org.example.daybook.uniffi.types.RustBuffer as RustBufferFacetDisplayHint // This is a helper for safely working with byte buffers returned from the Rust code. // A rust-owned buffer is represented by its capacity, its current length, and a @@ -1477,7 +1477,7 @@ external fun uniffi_daybook_ffi_fn_clone_rtffi(`handle`: Long,uniffi_out_err: Un ): Long external fun uniffi_daybook_ffi_fn_free_rtffi(`handle`: Long,uniffi_out_err: UniffiRustCallStatus, ): Unit -external fun uniffi_daybook_ffi_fn_constructor_rtffi_load(`fcx`: Long,`drawerRepo`: Long,`plugsRepo`: Long,`dispatchRepo`: Long,`progressRepo`: Long,`blobsRepo`: Long,`configRepo`: Long,`deviceId`: RustBuffer.ByValue, +external fun uniffi_daybook_ffi_fn_constructor_rtffi_load(`fcx`: Long,`drawerRepo`: Long,`plugsRepo`: Long,`dispatchRepo`: Long,`progressRepo`: Long,`blobsRepo`: Long,`configRepo`: Long,`deviceId`: RustBuffer.ByValue,`startupProgressTaskId`: RustBuffer.ByValue, ): Long external fun uniffi_daybook_ffi_fn_method_rtffi_dispatch_doc_facet(`ptr`: Long,`plugId`: RustBuffer.ByValue,`routineName`: RustBuffer.ByValue,`docId`: RustBuffer.ByValue,`branchPath`: RustBuffer.ByValue, ): Long @@ -1689,19 +1689,19 @@ private fun uniffiCheckApiChecksums(lib: IntegrityCheckingUniffiLib) { if (lib.uniffi_daybook_ffi_checksum_method_configrepoffi_ffi_register_listener() != 12494.toShort()) { throw RuntimeException("UniFFI API checksum mismatch: try cleaning and rebuilding your project") } - if (lib.uniffi_daybook_ffi_checksum_method_configrepoffi_get_facet_display_hint() != 14702.toShort()) { + if (lib.uniffi_daybook_ffi_checksum_method_configrepoffi_get_facet_display_hint() != 1644.toShort()) { throw RuntimeException("UniFFI API checksum mismatch: try cleaning and rebuilding your project") } if (lib.uniffi_daybook_ffi_checksum_method_configrepoffi_get_mltools_config_json() != 36447.toShort()) { throw RuntimeException("UniFFI API checksum mismatch: try cleaning and rebuilding your project") } - if (lib.uniffi_daybook_ffi_checksum_method_configrepoffi_list_display_hints() != 47455.toShort()) { + if (lib.uniffi_daybook_ffi_checksum_method_configrepoffi_list_display_hints() != 60117.toShort()) { throw RuntimeException("UniFFI API checksum mismatch: try cleaning and rebuilding your project") } if (lib.uniffi_daybook_ffi_checksum_method_configrepoffi_provision_mobile_default_mltools() != 36327.toShort()) { throw RuntimeException("UniFFI API checksum mismatch: try cleaning and rebuilding your project") } - if (lib.uniffi_daybook_ffi_checksum_method_configrepoffi_set_facet_display_hint() != 7725.toShort()) { + if (lib.uniffi_daybook_ffi_checksum_method_configrepoffi_set_facet_display_hint() != 8753.toShort()) { throw RuntimeException("UniFFI API checksum mismatch: try cleaning and rebuilding your project") } if (lib.uniffi_daybook_ffi_checksum_method_configrepoffi_set_mltools_config_json() != 35501.toShort()) { @@ -1926,7 +1926,7 @@ private fun uniffiCheckApiChecksums(lib: IntegrityCheckingUniffiLib) { if (lib.uniffi_daybook_ffi_checksum_constructor_tablesrepoffi_load() != 40277.toShort()) { throw RuntimeException("UniFFI API checksum mismatch: try cleaning and rebuilding your project") } - if (lib.uniffi_daybook_ffi_checksum_constructor_rtffi_load() != 49748.toShort()) { + if (lib.uniffi_daybook_ffi_checksum_constructor_rtffi_load() != 48058.toShort()) { throw RuntimeException("UniFFI API checksum mismatch: try cleaning and rebuilding your project") } } @@ -8735,9 +8735,9 @@ open class RtFfi: Disposable, AutoCloseable, RtFfiInterface @Throws(FfiException::class) @Suppress("ASSIGNED_BUT_NEVER_ACCESSED_VARIABLE") - suspend fun `load`(`fcx`: FfiCtx, `drawerRepo`: DrawerRepoFfi, `plugsRepo`: PlugsRepoFfi, `dispatchRepo`: DispatchRepoFfi, `progressRepo`: ProgressRepoFfi, `blobsRepo`: BlobsRepoFfi, `configRepo`: ConfigRepoFfi, `deviceId`: kotlin.String) : RtFfi { + suspend fun `load`(`fcx`: FfiCtx, `drawerRepo`: DrawerRepoFfi, `plugsRepo`: PlugsRepoFfi, `dispatchRepo`: DispatchRepoFfi, `progressRepo`: ProgressRepoFfi, `blobsRepo`: BlobsRepoFfi, `configRepo`: ConfigRepoFfi, `deviceId`: kotlin.String, `startupProgressTaskId`: kotlin.String?) : RtFfi { return uniffiRustCallAsync( - UniffiLib.uniffi_daybook_ffi_fn_constructor_rtffi_load(FfiConverterTypeFfiCtx.lower(`fcx`),FfiConverterTypeDrawerRepoFfi.lower(`drawerRepo`),FfiConverterTypePlugsRepoFfi.lower(`plugsRepo`),FfiConverterTypeDispatchRepoFfi.lower(`dispatchRepo`),FfiConverterTypeProgressRepoFfi.lower(`progressRepo`),FfiConverterTypeBlobsRepoFfi.lower(`blobsRepo`),FfiConverterTypeConfigRepoFfi.lower(`configRepo`),FfiConverterString.lower(`deviceId`),), + UniffiLib.uniffi_daybook_ffi_fn_constructor_rtffi_load(FfiConverterTypeFfiCtx.lower(`fcx`),FfiConverterTypeDrawerRepoFfi.lower(`drawerRepo`),FfiConverterTypePlugsRepoFfi.lower(`plugsRepo`),FfiConverterTypeDispatchRepoFfi.lower(`dispatchRepo`),FfiConverterTypeProgressRepoFfi.lower(`progressRepo`),FfiConverterTypeBlobsRepoFfi.lower(`blobsRepo`),FfiConverterTypeConfigRepoFfi.lower(`configRepo`),FfiConverterString.lower(`deviceId`),FfiConverterOptionalString.lower(`startupProgressTaskId`),), { future, callback, continuation -> UniffiLib.ffi_daybook_ffi_rust_future_poll_u64(future, callback, continuation) }, { future, continuation -> UniffiLib.ffi_daybook_ffi_rust_future_complete_u64(future, continuation) }, { future -> UniffiLib.ffi_daybook_ffi_rust_future_free_u64(future) }, @@ -10713,38 +10713,6 @@ public object FfiConverterOptionalTypeDocEntry: FfiConverterRustBuffer { - override fun read(buf: ByteBuffer): FacetDisplayHint? { - if (buf.get().toInt() == 0) { - return null - } - return FfiConverterTypeFacetDisplayHint.read(buf) - } - - override fun allocationSize(value: FacetDisplayHint?): ULong { - if (value == null) { - return 1UL - } else { - return 1UL + FfiConverterTypeFacetDisplayHint.allocationSize(value) - } - } - - override fun write(value: FacetDisplayHint?, buf: ByteBuffer) { - if (value == null) { - buf.put(0) - } else { - buf.put(1) - FfiConverterTypeFacetDisplayHint.write(value, buf) - } - } -} - - - - /** * @suppress */ @@ -10969,6 +10937,38 @@ public object FfiConverterOptionalTypeDoc: FfiConverterRustBuffer { +/** + * @suppress + */ +public object FfiConverterOptionalTypeFacetDisplayHint: FfiConverterRustBuffer { + override fun read(buf: ByteBuffer): FacetDisplayHint? { + if (buf.get().toInt() == 0) { + return null + } + return FfiConverterTypeFacetDisplayHint.read(buf) + } + + override fun allocationSize(value: FacetDisplayHint?): ULong { + if (value == null) { + return 1UL + } else { + return 1UL + FfiConverterTypeFacetDisplayHint.allocationSize(value) + } + } + + override fun write(value: FacetDisplayHint?, buf: ByteBuffer) { + if (value == null) { + buf.put(0) + } else { + buf.put(1) + FfiConverterTypeFacetDisplayHint.write(value, buf) + } + } +} + + + + /** * @suppress */ @@ -11528,3 +11528,66 @@ public object FfiConverterTypeUuid: FfiConverter { FfiConverterByteArray.write(builtinValue, buf) } } + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/daybook_compose/composeApp/src/commonMain/kotlin/org/example/daybook/uniffi/types/daybook_types.kt b/src/daybook_compose/composeApp/src/commonMain/kotlin/org/example/daybook/uniffi/types/daybook_types.kt index a45b3863..6ce412db 100644 --- a/src/daybook_compose/composeApp/src/commonMain/kotlin/org/example/daybook/uniffi/types/daybook_types.kt +++ b/src/daybook_compose/composeApp/src/commonMain/kotlin/org/example/daybook/uniffi/types/daybook_types.kt @@ -956,6 +956,29 @@ public object FfiConverterFloat: FfiConverter { } } +/** + * @suppress + */ +public object FfiConverterBoolean: FfiConverter { + override fun lift(value: Byte): Boolean { + return value.toInt() != 0 + } + + override fun read(buf: ByteBuffer): Boolean { + return lift(buf.get()) + } + + override fun lower(value: Boolean): Byte { + return if (value) 1.toByte() else 0.toByte() + } + + override fun allocationSize(value: Boolean) = 1UL + + override fun write(value: Boolean, buf: ByteBuffer) { + buf.put(lower(value)) + } +} + /** * @suppress */ @@ -1429,6 +1452,49 @@ public object FfiConverterTypeEmbedding: FfiConverterRustBuffer { +data class FacetDisplayHint ( + var `alwaysVisible`: kotlin.Boolean + , + var `displayTitle`: kotlin.String? + , + var `deets`: FacetKeyDisplayDeets + +){ + + + + + + companion object +} + +/** + * @suppress + */ +public object FfiConverterTypeFacetDisplayHint: FfiConverterRustBuffer { + override fun read(buf: ByteBuffer): FacetDisplayHint { + return FacetDisplayHint( + FfiConverterBoolean.read(buf), + FfiConverterOptionalString.read(buf), + FfiConverterTypeFacetKeyDisplayDeets.read(buf), + ) + } + + override fun allocationSize(value: FacetDisplayHint) = ( + FfiConverterBoolean.allocationSize(value.`alwaysVisible`) + + FfiConverterOptionalString.allocationSize(value.`displayTitle`) + + FfiConverterTypeFacetKeyDisplayDeets.allocationSize(value.`deets`) + ) + + override fun write(value: FacetDisplayHint, buf: ByteBuffer) { + FfiConverterBoolean.write(value.`alwaysVisible`, buf) + FfiConverterOptionalString.write(value.`displayTitle`, buf) + FfiConverterTypeFacetKeyDisplayDeets.write(value.`deets`, buf) + } +} + + + data class FacetKey ( var `tag`: FacetTag , @@ -1473,6 +1539,8 @@ data class FacetMeta ( var `uuid`: List , var `updatedAt`: List + , + var `deletedAt`: List ){ @@ -1492,19 +1560,22 @@ public object FfiConverterTypeFacetMeta: FfiConverterRustBuffer { FfiConverterTypeTimestamp.read(buf), FfiConverterSequenceTypeUuid.read(buf), FfiConverterSequenceTypeTimestamp.read(buf), + FfiConverterSequenceTypeTimestamp.read(buf), ) } override fun allocationSize(value: FacetMeta) = ( FfiConverterTypeTimestamp.allocationSize(value.`createdAt`) + FfiConverterSequenceTypeUuid.allocationSize(value.`uuid`) + - FfiConverterSequenceTypeTimestamp.allocationSize(value.`updatedAt`) + FfiConverterSequenceTypeTimestamp.allocationSize(value.`updatedAt`) + + FfiConverterSequenceTypeTimestamp.allocationSize(value.`deletedAt`) ) override fun write(value: FacetMeta, buf: ByteBuffer) { FfiConverterTypeTimestamp.write(value.`createdAt`, buf) FfiConverterSequenceTypeUuid.write(value.`uuid`, buf) FfiConverterSequenceTypeTimestamp.write(value.`updatedAt`, buf) + FfiConverterSequenceTypeTimestamp.write(value.`deletedAt`, buf) } } @@ -1776,12 +1847,8 @@ public object FfiConverterTypePoint: FfiConverterRustBuffer { -data class PseudoLabelCandidate ( - var `label`: kotlin.String - , - var `prompts`: List - , - var `negativePrompts`: List +data class UserMeta ( + var `userPath`: Utf8PathBuf ){ @@ -1795,97 +1862,61 @@ data class PseudoLabelCandidate ( /** * @suppress */ -public object FfiConverterTypePseudoLabelCandidate: FfiConverterRustBuffer { - override fun read(buf: ByteBuffer): PseudoLabelCandidate { - return PseudoLabelCandidate( - FfiConverterString.read(buf), - FfiConverterSequenceString.read(buf), - FfiConverterSequenceString.read(buf), +public object FfiConverterTypeUserMeta: FfiConverterRustBuffer { + override fun read(buf: ByteBuffer): UserMeta { + return UserMeta( + FfiConverterTypeUtf8PathBuf.read(buf), ) } - override fun allocationSize(value: PseudoLabelCandidate) = ( - FfiConverterString.allocationSize(value.`label`) + - FfiConverterSequenceString.allocationSize(value.`prompts`) + - FfiConverterSequenceString.allocationSize(value.`negativePrompts`) + override fun allocationSize(value: UserMeta) = ( + FfiConverterTypeUtf8PathBuf.allocationSize(value.`userPath`) ) - override fun write(value: PseudoLabelCandidate, buf: ByteBuffer) { - FfiConverterString.write(value.`label`, buf) - FfiConverterSequenceString.write(value.`prompts`, buf) - FfiConverterSequenceString.write(value.`negativePrompts`, buf) + override fun write(value: UserMeta, buf: ByteBuffer) { + FfiConverterTypeUtf8PathBuf.write(value.`userPath`, buf) } } -data class PseudoLabelCandidatesFacet ( - var `labels`: List - -){ - +enum class DateTimeFacetDisplayType { + TIME_AND_DATE, + RELATIVE, + TIME_ONLY, + DATE_ONLY; - companion object -} - -/** - * @suppress - */ -public object FfiConverterTypePseudoLabelCandidatesFacet: FfiConverterRustBuffer { - override fun read(buf: ByteBuffer): PseudoLabelCandidatesFacet { - return PseudoLabelCandidatesFacet( - FfiConverterSequenceTypePseudoLabelCandidate.read(buf), - ) - } - - override fun allocationSize(value: PseudoLabelCandidatesFacet) = ( - FfiConverterSequenceTypePseudoLabelCandidate.allocationSize(value.`labels`) - ) - - override fun write(value: PseudoLabelCandidatesFacet, buf: ByteBuffer) { - FfiConverterSequenceTypePseudoLabelCandidate.write(value.`labels`, buf) - } -} - - - -data class UserMeta ( - var `userPath`: Utf8PathBuf - -){ - - - companion object } + /** * @suppress */ -public object FfiConverterTypeUserMeta: FfiConverterRustBuffer { - override fun read(buf: ByteBuffer): UserMeta { - return UserMeta( - FfiConverterTypeUtf8PathBuf.read(buf), - ) +public object FfiConverterTypeDateTimeFacetDisplayType: FfiConverterRustBuffer { + override fun read(buf: ByteBuffer) = try { + DateTimeFacetDisplayType.values()[buf.getInt() - 1] + } catch (e: IndexOutOfBoundsException) { + throw RuntimeException("invalid enum value, something is very wrong!!", e) } - override fun allocationSize(value: UserMeta) = ( - FfiConverterTypeUtf8PathBuf.allocationSize(value.`userPath`) - ) + override fun allocationSize(value: DateTimeFacetDisplayType) = 4UL - override fun write(value: UserMeta, buf: ByteBuffer) { - FfiConverterTypeUtf8PathBuf.write(value.`userPath`, buf) + override fun write(value: DateTimeFacetDisplayType, buf: ByteBuffer) { + buf.putInt(value.ordinal + 1) } } + + enum class EmbeddingCompression { ZSTD; @@ -1954,6 +1985,117 @@ public object FfiConverterTypeEmbeddingDtype: FfiConverterRustBuffer{ + override fun read(buf: ByteBuffer): FacetKeyDisplayDeets { + return when(buf.getInt()) { + 1 -> FacetKeyDisplayDeets.DebugPrint + 2 -> FacetKeyDisplayDeets.DateTime( + FfiConverterTypeDateTimeFacetDisplayType.read(buf), + ) + 3 -> FacetKeyDisplayDeets.UnixPath + 4 -> FacetKeyDisplayDeets.Title( + FfiConverterBoolean.read(buf), + ) + else -> throw RuntimeException("invalid enum value, something is very wrong!!") + } + } + + override fun allocationSize(value: FacetKeyDisplayDeets) = when(value) { + is FacetKeyDisplayDeets.DebugPrint -> { + // Add the size for the Int that specifies the variant plus the size needed for all fields + ( + 4UL + ) + } + is FacetKeyDisplayDeets.DateTime -> { + // Add the size for the Int that specifies the variant plus the size needed for all fields + ( + 4UL + + FfiConverterTypeDateTimeFacetDisplayType.allocationSize(value.`displayType`) + ) + } + is FacetKeyDisplayDeets.UnixPath -> { + // Add the size for the Int that specifies the variant plus the size needed for all fields + ( + 4UL + ) + } + is FacetKeyDisplayDeets.Title -> { + // Add the size for the Int that specifies the variant plus the size needed for all fields + ( + 4UL + + FfiConverterBoolean.allocationSize(value.`showEditor`) + ) + } + } + + override fun write(value: FacetKeyDisplayDeets, buf: ByteBuffer) { + when(value) { + is FacetKeyDisplayDeets.DebugPrint -> { + buf.putInt(1) + Unit + } + is FacetKeyDisplayDeets.DateTime -> { + buf.putInt(2) + FfiConverterTypeDateTimeFacetDisplayType.write(value.`displayType`, buf) + Unit + } + is FacetKeyDisplayDeets.UnixPath -> { + buf.putInt(3) + Unit + } + is FacetKeyDisplayDeets.Title -> { + buf.putInt(4) + FfiConverterBoolean.write(value.`showEditor`, buf) + Unit + } + }.let { /* this makes the `when` an expression, which ensures it is exhaustive */ } + } +} + + + + + sealed class FacetTag { data class WellKnown( @@ -2066,24 +2208,6 @@ sealed class WellKnownFacet { companion object } - data class PseudoLabel( - val v1: List) : WellKnownFacet() - - { - - - companion object - } - - data class PseudoLabelCandidates( - val v1: org.example.daybook.uniffi.types.PseudoLabelCandidatesFacet) : WellKnownFacet() - - { - - - companion object - } - data class TitleGeneric( val v1: kotlin.String) : WellKnownFacet() @@ -2190,37 +2314,31 @@ public object FfiConverterTypeWellKnownFacet : FfiConverterRustBuffer WellKnownFacet.LabelGeneric( FfiConverterString.read(buf), ) - 4 -> WellKnownFacet.PseudoLabel( - FfiConverterSequenceString.read(buf), - ) - 5 -> WellKnownFacet.PseudoLabelCandidates( - FfiConverterTypePseudoLabelCandidatesFacet.read(buf), - ) - 6 -> WellKnownFacet.TitleGeneric( + 4 -> WellKnownFacet.TitleGeneric( FfiConverterString.read(buf), ) - 7 -> WellKnownFacet.PathGeneric( + 5 -> WellKnownFacet.PathGeneric( FfiConverterString.read(buf), ) - 8 -> WellKnownFacet.Pending( + 6 -> WellKnownFacet.Pending( FfiConverterTypePending.read(buf), ) - 9 -> WellKnownFacet.Body( + 7 -> WellKnownFacet.Body( FfiConverterTypeBody.read(buf), ) - 10 -> WellKnownFacet.Note( + 8 -> WellKnownFacet.Note( FfiConverterTypeNote.read(buf), ) - 11 -> WellKnownFacet.Blob( + 9 -> WellKnownFacet.Blob( FfiConverterTypeBlob.read(buf), ) - 12 -> WellKnownFacet.ImageMetadata( + 10 -> WellKnownFacet.ImageMetadata( FfiConverterTypeImageMetadata.read(buf), ) - 13 -> WellKnownFacet.OcrResult( + 11 -> WellKnownFacet.OcrResult( FfiConverterTypeOcrResult.read(buf), ) - 14 -> WellKnownFacet.Embedding( + 12 -> WellKnownFacet.Embedding( FfiConverterTypeEmbedding.read(buf), ) else -> throw RuntimeException("invalid enum value, something is very wrong!!") @@ -2249,20 +2367,6 @@ public object FfiConverterTypeWellKnownFacet : FfiConverterRustBuffer { - // Add the size for the Int that specifies the variant plus the size needed for all fields - ( - 4UL - + FfiConverterSequenceString.allocationSize(value.v1) - ) - } - is WellKnownFacet.PseudoLabelCandidates -> { - // Add the size for the Int that specifies the variant plus the size needed for all fields - ( - 4UL - + FfiConverterTypePseudoLabelCandidatesFacet.allocationSize(value.v1) - ) - } is WellKnownFacet.TitleGeneric -> { // Add the size for the Int that specifies the variant plus the size needed for all fields ( @@ -2345,58 +2449,48 @@ public object FfiConverterTypeWellKnownFacet : FfiConverterRustBuffer { - buf.putInt(4) - FfiConverterSequenceString.write(value.v1, buf) - Unit - } - is WellKnownFacet.PseudoLabelCandidates -> { - buf.putInt(5) - FfiConverterTypePseudoLabelCandidatesFacet.write(value.v1, buf) - Unit - } is WellKnownFacet.TitleGeneric -> { - buf.putInt(6) + buf.putInt(4) FfiConverterString.write(value.v1, buf) Unit } is WellKnownFacet.PathGeneric -> { - buf.putInt(7) + buf.putInt(5) FfiConverterString.write(value.v1, buf) Unit } is WellKnownFacet.Pending -> { - buf.putInt(8) + buf.putInt(6) FfiConverterTypePending.write(value.v1, buf) Unit } is WellKnownFacet.Body -> { - buf.putInt(9) + buf.putInt(7) FfiConverterTypeBody.write(value.v1, buf) Unit } is WellKnownFacet.Note -> { - buf.putInt(10) + buf.putInt(8) FfiConverterTypeNote.write(value.v1, buf) Unit } is WellKnownFacet.Blob -> { - buf.putInt(11) + buf.putInt(9) FfiConverterTypeBlob.write(value.v1, buf) Unit } is WellKnownFacet.ImageMetadata -> { - buf.putInt(12) + buf.putInt(10) FfiConverterTypeImageMetadata.write(value.v1, buf) Unit } is WellKnownFacet.OcrResult -> { - buf.putInt(13) + buf.putInt(11) FfiConverterTypeOcrResult.write(value.v1, buf) Unit } is WellKnownFacet.Embedding -> { - buf.putInt(14) + buf.putInt(12) FfiConverterTypeEmbedding.write(value.v1, buf) Unit } @@ -2414,8 +2508,6 @@ enum class WellKnownFacetTag { DMETA, REF_GENERIC, LABEL_GENERIC, - PSEUDO_LABEL, - PSEUDO_LABEL_CANDIDATES, TITLE_GENERIC, PATH_GENERIC, PENDING, @@ -2791,34 +2883,6 @@ public object FfiConverterSequenceTypePoint: FfiConverterRustBuffer> -/** - * @suppress - */ -public object FfiConverterSequenceTypePseudoLabelCandidate: FfiConverterRustBuffer> { - override fun read(buf: ByteBuffer): List { - val len = buf.getInt() - return List(len) { - FfiConverterTypePseudoLabelCandidate.read(buf) - } - } - - override fun allocationSize(value: List): ULong { - val sizeForLength = 4UL - val sizeForItems = value.map { FfiConverterTypePseudoLabelCandidate.allocationSize(it) }.sum() - return sizeForLength + sizeForItems - } - - override fun write(value: List, buf: ByteBuffer) { - buf.putInt(value.size) - value.iterator().forEach { - FfiConverterTypePseudoLabelCandidate.write(it, buf) - } - } -} - - - - /** * @suppress */ @@ -3179,3 +3243,4 @@ public object FfiConverterTypeUuid: FfiConverter { FfiConverterByteArray.write(builtinValue, buf) } } + diff --git a/src/daybook_compose/composeApp/src/commonMain/kotlin/org/example/daybook/welcome/helpers.kt b/src/daybook_compose/composeApp/src/commonMain/kotlin/org/example/daybook/welcome/helpers.kt index 1ccd53e9..f2ad3dd0 100644 --- a/src/daybook_compose/composeApp/src/commonMain/kotlin/org/example/daybook/welcome/helpers.kt +++ b/src/daybook_compose/composeApp/src/commonMain/kotlin/org/example/daybook/welcome/helpers.kt @@ -1,14 +1,18 @@ package org.example.daybook +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext import org.example.daybook.uniffi.AppFfiCtx import org.example.daybook.uniffi.FfiException internal suspend inline fun withAppFfiCtx(crossinline block: suspend (AppFfiCtx) -> T): T { - val gcx = AppFfiCtx.init() - try { - return block(gcx) - } finally { - gcx.close() + return withContext(Dispatchers.IO) { + val gcx = AppFfiCtx.init() + try { + block(gcx) + } finally { + gcx.close() + } } } diff --git a/src/daybook_compose/composeApp/src/commonMain/kotlin/org/example/daybook/welcome/welcome.kt b/src/daybook_compose/composeApp/src/commonMain/kotlin/org/example/daybook/welcome/welcome.kt index 117a32bf..79c26402 100644 --- a/src/daybook_compose/composeApp/src/commonMain/kotlin/org/example/daybook/welcome/welcome.kt +++ b/src/daybook_compose/composeApp/src/commonMain/kotlin/org/example/daybook/welcome/welcome.kt @@ -91,6 +91,8 @@ import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.launch import kotlinx.coroutines.CancellationException import kotlinx.coroutines.delay +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext import io.github.vinceglb.filekit.dialogs.compose.rememberDirectoryPickerLauncher import io.github.vinceglb.filekit.path import org.example.daybook.capture.CameraCaptureContext @@ -188,7 +190,7 @@ private object WelcomeRoute { fun WelcomeFlowNavHost( repos: List, permCtx: PermissionsContext?, - cameraPreviewFfi: CameraPreviewFfi, + cameraPreviewFfi: CameraPreviewFfi?, selectedWelcomeRepo: KnownRepoEntry?, cloneUiState: CloneUiState?, createRepoUiState: CreateRepoUiState?, @@ -574,13 +576,25 @@ fun WelcomeFlowNavHost( if (scannerState == null) { LaunchedEffect(Unit) { navController.popBackStack() } } else { - CloneQrScannerScreen( - cameraPreviewFfi = cameraPreviewFfi, - onDetectedUrl = { detectedUrl -> - onCloneUiStateChange(CloneUiState.UrlInput(urlInput = detectedUrl)) - navController.popBackStack(WelcomeRoute.CloneUrl, false) + if (cameraPreviewFfi == null) { + Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + CircularProgressIndicator() + Text("Initializing camera…", style = MaterialTheme.typography.bodyMedium) + } } - ) + } else { + CloneQrScannerScreen( + cameraPreviewFfi = cameraPreviewFfi, + onDetectedUrl = { detectedUrl -> + onCloneUiStateChange(CloneUiState.UrlInput(urlInput = detectedUrl)) + navController.popBackStack(WelcomeRoute.CloneUrl, false) + } + ) + } } } @@ -1034,14 +1048,32 @@ private fun CloneQrScannerScreen( candidate.matches(Regex("^[A-Za-z][A-Za-z0-9+.-]*:.*$")) val useNativePreviewQr = remember(cameraPreviewFfi) { cameraPreviewFfi.supportsNativeQrAnalysis() } - val analyzer = remember { CameraQrAnalyzerFfi.load() } + var analyzer by remember { mutableStateOf(null) } + LaunchedEffect(Unit) { + if (analyzer == null) { + analyzer = withContext(Dispatchers.IO) { CameraQrAnalyzerFfi.load() } + } + } + val analyzerReady = analyzer + if (analyzerReady == null) { + Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + CircularProgressIndicator() + Text("Initializing QR analyzer…", style = MaterialTheme.typography.bodyMedium) + } + } + return + } val uiScope = rememberCoroutineScope() var userVisibleError by remember { mutableStateOf(null) } var hasCompleted by remember { mutableStateOf(false) } val frameBridge = - remember(analyzer) { + remember(analyzerReady) { CameraQrOverlayBridge( - analyzer = analyzer, + analyzer = analyzerReady, onDetectedText = { rawText -> uiScope.launch { if (hasCompleted) return@launch @@ -1076,7 +1108,7 @@ private fun CloneQrScannerScreen( } val overlayState by (if (useNativePreviewQr) previewBridge.state else frameBridge.state).collectAsState() - androidx.compose.runtime.DisposableEffect(analyzer, frameBridge, previewBridge, useNativePreviewQr) { + androidx.compose.runtime.DisposableEffect(analyzerReady, frameBridge, previewBridge, useNativePreviewQr) { if (useNativePreviewQr) { previewBridge.start() } else { @@ -1085,7 +1117,7 @@ private fun CloneQrScannerScreen( onDispose { previewBridge.stop() frameBridge.stop() - analyzer.close() + analyzerReady.close() } } @@ -1327,6 +1359,10 @@ fun CloneShareDialogContent( errorMessage = null ticketUrl = null qrPngBytes = null + if (syncRepo == null) { + errorMessage = "Sync service is still starting. Try again in a moment." + return@LaunchedEffect + } try { val ticket = syncRepo.getTicketWithQrPng(768u) ticketUrl = ticket.ticketUrl diff --git a/src/daybook_compose/composeApp/src/desktopMain/kotlin/org/example/daybook/main.kt b/src/daybook_compose/composeApp/src/desktopMain/kotlin/org/example/daybook/main.kt index 98ec20d0..dfd125f7 100644 --- a/src/daybook_compose/composeApp/src/desktopMain/kotlin/org/example/daybook/main.kt +++ b/src/daybook_compose/composeApp/src/desktopMain/kotlin/org/example/daybook/main.kt @@ -1,6 +1,8 @@ package org.example.daybook import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.unit.Density import androidx.compose.ui.unit.DpSize @@ -8,8 +10,29 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.window.Window import androidx.compose.ui.window.application import androidx.compose.ui.window.rememberWindowState +import kotlinx.coroutines.delay +import java.awt.EventQueue +import sun.misc.Signal +import java.util.concurrent.atomic.AtomicBoolean + +private val signalShutdownRequested = AtomicBoolean(false) + +private fun installSignalHandler(signalName: String) { + runCatching { + Signal.handle(Signal(signalName)) { + println("[APP_SHUTDOWN] signal received name=$signalName, requesting graceful shutdown") + signalShutdownRequested.set(true) + } + println("[APP_SHUTDOWN] installed signal handler name=$signalName") + }.onFailure { error -> + println("[APP_SHUTDOWN] failed to install signal handler name=$signalName err=${error.message}") + } +} fun main() = application { + installSignalHandler("INT") + installSignalHandler("TERM") + val windowState = rememberWindowState( // FIXME: niri/xwayland doesn't like the javafx resize-logic @@ -18,11 +41,32 @@ fun main() = application { // size = DpSize((0.75 * 2560).dp, (1600 - 20).dp) size = DpSize((0.75 * 1600).dp, (900 - 20).dp) ) + DisposableEffect(Unit) { + val hook = Thread { println("[APP_SHUTDOWN] JVM shutdown hook triggered (signal/process exit)") } + Runtime.getRuntime().addShutdownHook(hook) + onDispose { + runCatching { Runtime.getRuntime().removeShutdownHook(hook) } + } + } + Window( - onCloseRequest = ::exitApplication, + onCloseRequest = { + println("[APP_SHUTDOWN] start: close requested, beginning graceful shutdown") + exitApplication() + }, title = "Daybook", state = windowState ) { + LaunchedEffect(Unit) { + while (true) { + if (signalShutdownRequested.getAndSet(false)) { + println("[APP_SHUTDOWN] start: signal-triggered graceful shutdown") + EventQueue.invokeLater { exitApplication() } + break + } + delay(100) + } + } CompositionLocalProvider( LocalDensity provides Density( @@ -42,7 +86,8 @@ fun main() = application { ) { App( extraAction = { - } + }, + autoShutdownOnDispose = true ) } } diff --git a/src/daybook_compose/composeApp/src/iosMain/kotlin/org/example/daybook/MainViewController.kt b/src/daybook_compose/composeApp/src/iosMain/kotlin/org/example/daybook/MainViewController.kt index aa7351e5..c092e58b 100644 --- a/src/daybook_compose/composeApp/src/iosMain/kotlin/org/example/daybook/MainViewController.kt +++ b/src/daybook_compose/composeApp/src/iosMain/kotlin/org/example/daybook/MainViewController.kt @@ -2,4 +2,4 @@ package org.example.daybook import androidx.compose.ui.window.ComposeUIViewController -fun MainViewController() = ComposeUIViewController { App() } +fun MainViewController() = ComposeUIViewController { App(autoShutdownOnDispose = false) } diff --git a/src/daybook_compose/composeApp/src/wasmJsMain/kotlin/org/example/daybook/main.kt b/src/daybook_compose/composeApp/src/wasmJsMain/kotlin/org/example/daybook/main.kt index ffe3563c..102d79a8 100644 --- a/src/daybook_compose/composeApp/src/wasmJsMain/kotlin/org/example/daybook/main.kt +++ b/src/daybook_compose/composeApp/src/wasmJsMain/kotlin/org/example/daybook/main.kt @@ -7,6 +7,6 @@ import kotlinx.browser.document @OptIn(ExperimentalComposeUiApi::class) fun main() { ComposeViewport(document.body!!) { - App() + App(autoShutdownOnDispose = false) } } diff --git a/src/daybook_core/drawer/cache.rs b/src/daybook_core/drawer/cache.rs index cc6b2e67..a799c8d7 100644 --- a/src/daybook_core/drawer/cache.rs +++ b/src/daybook_core/drawer/cache.rs @@ -177,6 +177,7 @@ impl FacetCacheState { } fn remove_without_pool(&mut self, key: &FacetCacheKey) { + self.seen_order.retain(|queued_key| queued_key != key); let removed = self.entries.remove(key); self.seen_once.remove(key); if removed.is_none() { diff --git a/src/daybook_core/index/doc_blobs.rs b/src/daybook_core/index/doc_blobs.rs index 19a953c1..b6831015 100644 --- a/src/daybook_core/index/doc_blobs.rs +++ b/src/daybook_core/index/doc_blobs.rs @@ -548,7 +548,7 @@ impl DocBlobsIndexRepo { ) -> Res> { let hashes: Vec = sqlx::query_scalar( r#" - SELECT blob_hash + SELECT DISTINCT blob_hash FROM doc_blob_refs WHERE doc_id = ?1 AND branch_path = ?2 diff --git a/src/daybook_core/repo.rs b/src/daybook_core/repo.rs index 2232fdb3..89c435bf 100644 --- a/src/daybook_core/repo.rs +++ b/src/daybook_core/repo.rs @@ -132,8 +132,11 @@ impl RepoCtx { options: RepoOpenOptions, local_device_name: String, ) -> Res { + info!(repo_root = %repo_root.display(), "repo open: starting"); let layout = repo_layout(repo_root)?; + info!(repo_root = %layout.repo_root.display(), lock_path = %layout.lock_path.display(), "repo open: acquiring lock"); let lock_guard = RepoLockGuard::acquire(&layout.lock_path)?; + info!(repo_root = %layout.repo_root.display(), "repo open: lock acquired"); if !is_repo_initialized(&layout.repo_root).await? { eyre::bail!( "repo not initialized at path {} (missing marker {})", @@ -141,6 +144,7 @@ impl RepoCtx { layout.marker_path.display() ); } + info!(repo_root = %layout.repo_root.display(), "repo open: marker check passed"); Self::open_inner(layout, lock_guard, options, local_device_name, false).await } @@ -149,14 +153,18 @@ impl RepoCtx { options: RepoOpenOptions, local_device_name: String, ) -> Res { + info!(repo_root = %repo_root.display(), "repo init: starting"); let layout = repo_layout(repo_root)?; + info!(repo_root = %layout.repo_root.display(), lock_path = %layout.lock_path.display(), "repo init: acquiring lock"); let lock_guard = RepoLockGuard::acquire(&layout.lock_path)?; + info!(repo_root = %layout.repo_root.display(), "repo init: lock acquired"); if is_repo_initialized(&layout.repo_root).await? { eyre::bail!( "repo already initialized at path {}", layout.repo_root.display() ); } + info!(repo_root = %layout.repo_root.display(), "repo init: marker absent, continuing"); Self::open_inner(layout, lock_guard, options, local_device_name, true).await } @@ -167,11 +175,18 @@ impl RepoCtx { local_device_name: String, initialize_repo: bool, ) -> Res { + info!( + repo_root = %layout.repo_root.display(), + initialize_repo, + "repo open_inner: begin" + ); cleanup_blobs_staging_dir(&layout.blobs_root).await?; + info!(repo_root = %layout.repo_root.display(), "repo open_inner: blobs staging cleaned"); let sql_config = SqlConfig { database_url: format!("sqlite://{}", layout.sqlite_path.display()), }; let sql = SqlCtx::new(&sql_config.database_url).await?; + info!(repo_root = %layout.repo_root.display(), sqlite_path = %layout.sqlite_path.display(), "repo open_inner: sqlite ready"); let repo_id = if initialize_repo { crate::app::globals::get_or_init_repo_id(&sql.db_pool).await? } else { @@ -179,6 +194,7 @@ impl RepoCtx { .await? .ok_or_eyre("repo_id missing in initialized repo")? }; + info!(repo_root = %layout.repo_root.display(), repo_id, "repo open_inner: repo id ready"); let identity = crate::secrets::SecretRepo::load_or_init_identity(&sql.db_pool, &repo_id).await?; let iroh_public_key = identity.iroh_public_key.to_string(); @@ -232,6 +248,7 @@ impl RepoCtx { Arc::clone(&partition_forwarders), )); partition_store.ensure_schema().await?; + info!(repo_root = %layout.repo_root.display(), "repo open_inner: partition store ready"); let (big_repo, big_repo_stop) = am_utils_rs::BigRepo::boot_with_partition_store( am_config, Arc::clone(&partition_store), @@ -239,6 +256,7 @@ impl RepoCtx { partition_forwarders, ) .await?; + info!(repo_root = %layout.repo_root.display(), "repo open_inner: big repo booted"); let doc_app_cell = tokio::sync::OnceCell::new(); let doc_drawer_cell = tokio::sync::OnceCell::new(); @@ -254,6 +272,7 @@ impl RepoCtx { }, ) .await?; + info!(repo_root = %layout.repo_root.display(), "repo open_inner: globals initialized"); let doc_app = doc_app_cell .get() .expect("doc_app cell should be initialized") @@ -262,14 +281,22 @@ impl RepoCtx { .get() .expect("doc_drawer cell should be initialized") .clone(); + info!( + repo_root = %layout.repo_root.display(), + doc_app_id = %doc_app.document_id(), + doc_drawer_id = %doc_drawer.document_id(), + "repo open_inner: core docs ready" + ); ensure_expected_partitions_for_docs( &big_repo, doc_app.document_id(), doc_drawer.document_id(), ) .await?; + info!(repo_root = %layout.repo_root.display(), "repo open_inner: core partitions ensured"); if initialize_repo { + info!(repo_root = %layout.repo_root.display(), "repo open_inner: running init dance"); Self::run_repo_init_dance( &big_repo, &doc_app, @@ -280,8 +307,10 @@ impl RepoCtx { ) .await?; mark_repo_initialized(&layout.repo_root).await?; + info!(repo_root = %layout.repo_root.display(), "repo open_inner: init marker written"); } + info!(repo_root = %layout.repo_root.display(), initialize_repo, "repo open_inner: completed"); Ok(Self { layout, lock_guard, @@ -308,6 +337,11 @@ impl RepoCtx { sql: &SqlitePool, blobs_root: std::path::PathBuf, ) -> Res<()> { + info!( + doc_app_id = %doc_app.document_id(), + doc_drawer_id = %doc_drawer.document_id(), + "repo init dance: starting" + ); use crate::blobs::BlobsRepo; use crate::config::ConfigRepo; use crate::drawer::DrawerRepo; @@ -323,6 +357,7 @@ impl RepoCtx { )), ) .await?; + info!("repo init dance: blobs repo loaded"); let mut plugs_repo: Option> = None; let mut plugs_stop: Option = None; let mut config_stop: Option = None; @@ -331,6 +366,7 @@ impl RepoCtx { let mut drawer_stop: Option = None; let init_result: Res<()> = async { + info!("repo init dance: loading plugs repo"); let (repo, stop) = PlugsRepo::load( Arc::clone(big_repo), Arc::clone(&blobs_repo), @@ -342,6 +378,7 @@ impl RepoCtx { plugs_repo = Some(repo); plugs_stop = Some(stop); + info!("repo init dance: loading config repo"); let (config_repo, stop) = ConfigRepo::load( Arc::clone(big_repo), doc_app.document_id().clone(), @@ -368,6 +405,7 @@ impl RepoCtx { .upsert_actor_user_path(plugs_actor_id, plugs_user_path) .await?; + info!("repo init dance: loading tables repo"); let (_tables_repo, stop) = TablesRepo::load( Arc::clone(big_repo), doc_app.document_id().clone(), @@ -384,6 +422,7 @@ impl RepoCtx { .upsert_actor_user_path(tables_actor_id, tables_user_path) .await?; + info!("repo init dance: loading dispatch repo"); let (_dispatch_repo, stop) = DispatchRepo::load( Arc::clone(big_repo), doc_app.document_id().clone(), @@ -401,6 +440,7 @@ impl RepoCtx { .upsert_actor_user_path(dispatch_actor_id, dispatch_user_path) .await?; + info!("repo init dance: loading drawer repo"); let (_drawer_repo, stop) = DrawerRepo::load( Arc::clone(big_repo), doc_drawer.document_id().clone(), @@ -434,17 +474,20 @@ impl RepoCtx { .upsert_actor_user_path(drawer_actor_id, drawer_user_path) .await?; + info!("repo init dance: ensuring system plugs"); plugs_repo .as_ref() .expect("plugs repo must be loaded") .ensure_system_plugs() .await?; + info!("repo init dance: system plugs ensured"); Ok(()) } .await; if let Err(err) = init_result { + info!(?err, "repo init dance: failed, starting cleanup"); if let Some(stop) = drawer_stop.take() { let _ = stop.stop().await; } @@ -465,9 +508,11 @@ impl RepoCtx { "error shutting down blobs repo during init cleanup: {shutdown_err:?}" ))); } + info!("repo init dance: cleanup finished after failure"); return Err(err); } + info!("repo init dance: stopping repos"); drawer_stop .expect("drawer stop token missing") .stop() @@ -486,6 +531,7 @@ impl RepoCtx { .stop() .await?; blobs_repo.shutdown().await?; + info!("repo init dance: completed"); Ok(()) } } @@ -665,11 +711,13 @@ impl AppCtx { options: RepoOpenOptions, local_device_name: String, ) -> Res { + info!(repo_root = %repo_root.display(), "app ctx: init_repo start"); let rcx = RepoCtx::init(repo_root, options, local_device_name).await?; if let Err(err) = upsert_known_repo(&self.sql.db_pool, repo_root).await { let _ = rcx.shutdown().await; return Err(err); } + info!(repo_root = %repo_root.display(), "app ctx: init_repo complete"); Ok(rcx) } @@ -679,11 +727,13 @@ impl AppCtx { options: RepoOpenOptions, local_device_name: String, ) -> Res { + info!(repo_root = %repo_root.display(), "app ctx: open_repo start"); let rcx = RepoCtx::open(repo_root, options, local_device_name).await?; if let Err(err) = upsert_known_repo(&self.sql.db_pool, repo_root).await { let _ = rcx.shutdown().await; return Err(err); } + info!(repo_root = %repo_root.display(), "app ctx: open_repo complete"); Ok(rcx) } } diff --git a/src/daybook_core/rt.rs b/src/daybook_core/rt.rs index f6cd7f1c..4cd16da9 100644 --- a/src/daybook_core/rt.rs +++ b/src/daybook_core/rt.rs @@ -49,6 +49,7 @@ pub struct ProcessorRunlogDone { pub struct RtConfig { pub device_id: String, + pub startup_progress_task_id: Option, } pub struct Rt { @@ -213,6 +214,39 @@ pub enum InvokeCommandFromWflowError { } impl Rt { + async fn emit_startup_progress_status( + progress_repo: &Arc, + startup_progress_task_id: Option<&str>, + message: String, + ) -> Res<()> { + let Some(task_id) = startup_progress_task_id else { + return Ok(()); + }; + progress_repo + .add_update( + task_id, + crate::progress::ProgressUpdate { + at: jiff::Timestamp::now(), + title: Some("App startup".to_string()), + deets: crate::progress::ProgressUpdateDeets::Status { + severity: crate::progress::ProgressSeverity::Info, + message, + }, + }, + ) + .await + } + + fn startup_timing_note( + stage_started: std::time::Instant, + total_started: std::time::Instant, + ) -> String { + let stage_ms = stage_started.elapsed().as_millis(); + let total_ms = total_started.elapsed().as_millis(); + let from_app_start = format!(" from_app_start_ms={}", utils_rs::app_startup_elapsed_ms()); + format!("stage_ms={stage_ms} total_ms={total_ms}{from_app_start}") + } + pub fn processor_runlog_item_id(doc_id: &str, processor_full_id: &str) -> String { format!("v1|doc:{doc_id}|proc:{processor_full_id}") } @@ -251,35 +285,90 @@ impl Rt { local_actor_id: ActorId, local_state_root: PathBuf, ) -> Res<(Arc, RtStopToken)> { + let total_started = std::time::Instant::now(); + let startup_progress_task_id = config.startup_progress_task_id.clone(); crate::repo::ensure_expected_partitions_for_docs( &big_repo, &app_doc_id, drawer.drawer_doc_id(), ) .await?; + Self::emit_startup_progress_status( + &progress_repo, + startup_progress_task_id.as_deref(), + "rt boot: ensured partitions".to_string(), + ) + .await?; + let wcx = wflow::Ctx::init(&wflow_db_url).await?; + Self::emit_startup_progress_status( + &progress_repo, + startup_progress_task_id.as_deref(), + "rt boot: initialized wflow ctx".to_string(), + ) + .await?; let (sqlite_local_state_repo, sqlite_local_state_stop) = SqliteLocalStateRepo::boot(local_state_root).await?; + Self::emit_startup_progress_status( + &progress_repo, + startup_progress_task_id.as_deref(), + "rt boot: loaded sqlite local state".to_string(), + ) + .await?; + + let stage_started = std::time::Instant::now(); let (init_repo, init_repo_stop) = InitRepo::load( Arc::clone(&big_repo), app_doc_id.clone(), local_actor_id.clone(), sql_pool, + Arc::clone(&progress_repo), + startup_progress_task_id.clone(), + ) + .await?; + Self::emit_startup_progress_status( + &progress_repo, + startup_progress_task_id.as_deref(), + format!( + "rt boot: loaded init repo ({})", + Self::startup_timing_note(stage_started, total_started) + ), ) .await?; + let stage_started = std::time::Instant::now(); let (doc_facet_set_index_repo, doc_facet_set_index_stop) = crate::index::DocFacetSetIndexRepo::boot( Arc::clone(&drawer), Arc::clone(&sqlite_local_state_repo), ) .await?; + Self::emit_startup_progress_status( + &progress_repo, + startup_progress_task_id.as_deref(), + format!( + "rt boot: loaded facet-set index ({})", + Self::startup_timing_note(stage_started, total_started) + ), + ) + .await?; + let stage_started = std::time::Instant::now(); let (doc_blobs_index_repo, doc_blobs_index_stop) = crate::index::DocBlobsIndexRepo::boot( Arc::clone(&drawer), Arc::clone(&blobs_repo), Arc::clone(&sqlite_local_state_repo), ) .await?; + Self::emit_startup_progress_status( + &progress_repo, + startup_progress_task_id.as_deref(), + format!( + "rt boot: loaded doc-blobs index ({})", + Self::startup_timing_note(stage_started, total_started) + ), + ) + .await?; + let stage_started = std::time::Instant::now(); let (doc_facet_ref_index_repo, doc_facet_ref_index_stop) = crate::index::DocFacetRefIndexRepo::boot( Arc::clone(&drawer), @@ -287,6 +376,15 @@ impl Rt { Arc::clone(&sqlite_local_state_repo), ) .await?; + Self::emit_startup_progress_status( + &progress_repo, + startup_progress_task_id.as_deref(), + format!( + "rt boot: loaded facet-ref index ({})", + Self::startup_timing_note(stage_started, total_started) + ), + ) + .await?; let wflow_plugin = Arc::new(wash_plugin_wflow::WflowPlugin::new(Arc::clone( &wcx.metastore, @@ -322,6 +420,12 @@ impl Rt { .await .to_eyre() .wrap_err("error starting wash host")?; + Self::emit_startup_progress_status( + &progress_repo, + startup_progress_task_id.as_deref(), + "rt boot: wash host started".to_string(), + ) + .await?; let mut bundles_to_load: HashSet<(String, String)> = default(); for (_dispatch_id, dispach) in dispatch_repo.list().await { @@ -336,6 +440,8 @@ impl Rt { } } for (plug_id, bundle_name) in bundles_to_load { + let plug_id_for_log = plug_id.clone(); + let bundle_name_for_log = bundle_name.clone(); let plug_man = plugs_repo.get(&plug_id).await.ok_or_else(|| { ferr!("plug with active dispatch not found in repo: plug={plug_id} bundle={bundle_name}") })?; @@ -352,11 +458,25 @@ impl Rt { bundle_man, ) .await?; + Self::emit_startup_progress_status( + &progress_repo, + startup_progress_task_id.as_deref(), + format!( + "rt boot: resumed workload plug={plug_id_for_log} bundle={bundle_name_for_log}" + ), + ) + .await?; } let part_idx = 0; let (wflow_part_handle, wflow_part_state) = wflow::start_partition_worker(&wcx, Arc::clone(&wflow_plugin), part_idx).await?; + Self::emit_startup_progress_status( + &progress_repo, + startup_progress_task_id.as_deref(), + "rt boot: partition worker started".to_string(), + ) + .await?; let part_log = PartitionLogRef::new(Arc::clone(&wcx.logstore)); let wflow_ingress = Arc::new(wflow::ingress::PartitionLogIngress::new( part_log, @@ -402,8 +522,23 @@ impl Rt { .collect::>(); plug_ids.sort(); for plug_id in plug_ids { - let _ = rt.ensure_plug_init_dispatches(&plug_id).await?; + let _ = rt + .ensure_plug_init_dispatches( + &plug_id, + startup_progress_task_id.as_deref(), + Some(total_started), + ) + .await?; } + Self::emit_startup_progress_status( + &rt.progress_repo, + startup_progress_task_id.as_deref(), + format!( + "rt boot: plug init queue complete ({})", + Self::startup_timing_note(total_started, total_started) + ), + ) + .await?; // Start the DocTriageWorker to automatically queue jobs when docs are added let switch_sinks: BTreeMap> = @@ -585,7 +720,13 @@ impl Rt { Ok(out) } - async fn ensure_plug_init_dispatches(&self, plug_id: &str) -> Res> { + async fn ensure_plug_init_dispatches( + &self, + plug_id: &str, + startup_progress_task_id: Option<&str>, + total_started: Option, + ) -> Res> { + let stage_started = std::time::Instant::now(); let order = self.collect_plug_and_dependency_order(plug_id).await?; let mut unresolved_init_dispatch_ids = vec![]; for plug_id in order { @@ -607,12 +748,40 @@ impl Rt { .is_done(&init_manifest.run_mode, &init_id) .await? { + self.init_repo + .report_boot_init_stage( + &init_manifest.run_mode, + &plug_id, + &init_key.0, + "already done", + init::BootInitProgressContext { + startup_progress_task_id_override: startup_progress_task_id + .map(str::to_owned), + stage_started, + total_started, + }, + ) + .await?; continue; } if let Some(running_dispatch_id) = self.init_repo.get_running_dispatch(&init_id).await { unresolved_init_dispatch_ids.push(running_dispatch_id); + self.init_repo + .report_boot_init_stage( + &init_manifest.run_mode, + &plug_id, + &init_key.0, + "running", + init::BootInitProgressContext { + startup_progress_task_id_override: startup_progress_task_id + .map(str::to_owned), + stage_started, + total_started, + }, + ) + .await?; continue; } let daybook_types::manifest::InitDeets::InvokeRoutine { routine_name } = @@ -649,6 +818,20 @@ impl Rt { .set_running_dispatch(&init_id, &dispatch_id) .await?; unresolved_init_dispatch_ids.push(dispatch_id); + self.init_repo + .report_boot_init_stage( + &init_manifest.run_mode, + &plug_id, + &init_key.0, + "queued", + init::BootInitProgressContext { + startup_progress_task_id_override: startup_progress_task_id + .map(str::to_owned), + stage_started, + total_started, + }, + ) + .await?; } } Ok(unresolved_init_dispatch_ids) @@ -1320,7 +1503,7 @@ impl Rt { format!("cmdinvoke-{encoded}") }; let waiting_on_dispatch_ids = self - .ensure_plug_init_dispatches(&target_ref.plug_id) + .ensure_plug_init_dispatches(&target_ref.plug_id, None, None) .await?; let staging_heads = self .drawer @@ -1364,7 +1547,9 @@ impl Rt { on_success_hooks: Vec, ) -> Res { self.ensure_rt_live()?; - let waiting_on_dispatch_ids = self.ensure_plug_init_dispatches(plug_id).await?; + let waiting_on_dispatch_ids = self + .ensure_plug_init_dispatches(plug_id, None, None) + .await?; self.dispatch_no_gate_internal( plug_id, routine_name, diff --git a/src/daybook_core/rt/init.rs b/src/daybook_core/rt/init.rs index cfcb027d..a2b01660 100644 --- a/src/daybook_core/rt/init.rs +++ b/src/daybook_core/rt/init.rs @@ -42,6 +42,8 @@ pub struct InitRepo { store: crate::stores::AmStoreHandle, local_actor_id: ActorId, sql_pool: sqlx::SqlitePool, + progress_repo: Arc, + startup_progress_task_id: Option, running_dispatches: tokio::sync::RwLock>, per_boot_done: tokio::sync::RwLock>, cancel_token: CancellationToken, @@ -50,6 +52,13 @@ pub struct InitRepo { _change_broker_leases: Vec>, } +#[derive(Clone)] +pub struct BootInitProgressContext { + pub startup_progress_task_id_override: Option, + pub stage_started: std::time::Instant, + pub total_started: Option, +} + impl crate::repos::Repo for InitRepo { type Event = InitEvent; fn registry(&self) -> &Arc { @@ -66,6 +75,8 @@ impl InitRepo { app_doc_id: DocumentId, local_actor_id: ActorId, sql_pool: sqlx::SqlitePool, + progress_repo: Arc, + startup_progress_task_id: Option, ) -> Res<(Arc, crate::repos::RepoStopToken)> { sqlx::query( r#" @@ -105,6 +116,8 @@ impl InitRepo { store, local_actor_id, sql_pool, + progress_repo, + startup_progress_task_id, running_dispatches: default(), per_boot_done: default(), cancel_token: cancel_token.clone(), @@ -354,4 +367,44 @@ impl InitRepo { } Ok(()) } + + pub async fn report_boot_init_stage( + &self, + run_mode: &daybook_types::manifest::InitRunMode, + plug_id: &str, + init_key: &str, + stage: &str, + ctx: BootInitProgressContext, + ) -> Res<()> { + if !matches!(run_mode, daybook_types::manifest::InitRunMode::PerBoot) { + return Ok(()); + } + let startup_task_id = ctx + .startup_progress_task_id_override + .or_else(|| self.startup_progress_task_id.clone()); + let Some(task_id) = startup_task_id else { + return Ok(()); + }; + let stage_ms = ctx.stage_started.elapsed().as_millis(); + let total_ms = ctx + .total_started + .map(|total| total.elapsed().as_millis()) + .unwrap_or(stage_ms); + let from_app_start_ms = utils_rs::app_startup_elapsed_ms(); + self.progress_repo + .add_update( + &task_id, + crate::progress::ProgressUpdate { + at: jiff::Timestamp::now(), + title: Some("App startup".to_string()), + deets: crate::progress::ProgressUpdateDeets::Status { + severity: crate::progress::ProgressSeverity::Info, + message: format!( + "rt init per-boot: {plug_id}/{init_key} {stage}; stage_ms={stage_ms} total_ms={total_ms} from_app_start_ms={from_app_start_ms}", + ), + }, + }, + ) + .await + } } diff --git a/src/daybook_core/test_support.rs b/src/daybook_core/test_support.rs index 800a889c..8db3c359 100644 --- a/src/daybook_core/test_support.rs +++ b/src/daybook_core/test_support.rs @@ -254,6 +254,7 @@ pub async fn test_cx_with_options( let (rt, rt_stop) = crate::rt::Rt::boot( crate::rt::RtConfig { device_id: device_id.clone(), + startup_progress_task_id: None, }, app_doc_id, wflow_db_url, diff --git a/src/daybook_ffi/ffi.rs b/src/daybook_ffi/ffi.rs index cdc830ba..5456ac14 100644 --- a/src/daybook_ffi/ffi.rs +++ b/src/daybook_ffi/ffi.rs @@ -224,6 +224,7 @@ impl AppFfiCtx { #[tracing::instrument(err)] async fn init() -> Result, FfiError> { utils_rs::setup_tracing_once(); + utils_rs::init_app_startup_clock(); let rt = crate::init_tokio()?; let rt = Arc::new(rt); diff --git a/src/daybook_ffi/repos/config.rs b/src/daybook_ffi/repos/config.rs index 59e5e5ce..ab6e7391 100644 --- a/src/daybook_ffi/repos/config.rs +++ b/src/daybook_ffi/repos/config.rs @@ -60,10 +60,15 @@ impl ConfigRepoFfi { } async fn stop(&self) -> Result<(), FfiError> { - if let Some(token) = self.stop_token.lock().await.take() { - token.stop().await?; - } - Ok(()) + let stop_token = self.stop_token.lock().await.take(); + self.fcx + .do_on_rt(async move { + if let Some(token) = stop_token { + token.stop().await?; + } + Ok::<(), FfiError>(()) + }) + .await } #[tracing::instrument(skip(self))] diff --git a/src/daybook_ffi/repos/dispatch.rs b/src/daybook_ffi/repos/dispatch.rs index d66a9855..da9ecb14 100644 --- a/src/daybook_ffi/repos/dispatch.rs +++ b/src/daybook_ffi/repos/dispatch.rs @@ -46,10 +46,15 @@ impl DispatchRepoFfi { } pub async fn stop(&self) -> Result<(), FfiError> { - if let Some(token) = self.stop_token.lock().await.take() { - token.stop().await?; - } - Ok(()) + let stop_token = self.stop_token.lock().await.take(); + self.fcx + .do_on_rt(async move { + if let Some(token) = stop_token { + token.stop().await?; + } + Ok::<(), FfiError>(()) + }) + .await } pub async fn list(self: Arc) -> Result, FfiError> { diff --git a/src/daybook_ffi/repos/drawer.rs b/src/daybook_ffi/repos/drawer.rs index 2028aefd..60d1f852 100644 --- a/src/daybook_ffi/repos/drawer.rs +++ b/src/daybook_ffi/repos/drawer.rs @@ -59,10 +59,15 @@ impl DrawerRepoFfi { } async fn stop(&self) -> Result<(), FfiError> { - if let Some(token) = self.stop_token.lock().await.take() { - token.stop().await?; - } - Ok(()) + let stop_token = self.stop_token.lock().await.take(); + self.fcx + .do_on_rt(async move { + if let Some(token) = stop_token { + token.stop().await?; + } + Ok::<(), FfiError>(()) + }) + .await } #[tracing::instrument(skip(self))] diff --git a/src/daybook_ffi/repos/plugs.rs b/src/daybook_ffi/repos/plugs.rs index 98a2ae47..50f9b345 100644 --- a/src/daybook_ffi/repos/plugs.rs +++ b/src/daybook_ffi/repos/plugs.rs @@ -5,7 +5,7 @@ use daybook_core::plugs::{PlugsEvent, PlugsRepo}; #[derive(uniffi::Object)] pub struct PlugsRepoFfi { - _fcx: SharedFfiCtx, + fcx: SharedFfiCtx, pub repo: Arc, stop_token: tokio::sync::Mutex>, } @@ -41,16 +41,21 @@ impl PlugsRepoFfi { .await .inspect_err(|err| tracing::error!(?err))?; Ok(Arc::new(Self { - _fcx: fcx, + fcx, repo, stop_token: Some(stop_token).into(), })) } async fn stop(&self) -> Result<(), FfiError> { - if let Some(token) = self.stop_token.lock().await.take() { - token.stop().await?; - } - Ok(()) + let stop_token = self.stop_token.lock().await.take(); + self.fcx + .do_on_rt(async move { + if let Some(token) = stop_token { + token.stop().await?; + } + Ok::<(), FfiError>(()) + }) + .await } } diff --git a/src/daybook_ffi/repos/progress.rs b/src/daybook_ffi/repos/progress.rs index 6b7a4a33..75cbd300 100644 --- a/src/daybook_ffi/repos/progress.rs +++ b/src/daybook_ffi/repos/progress.rs @@ -44,10 +44,15 @@ impl ProgressRepoFfi { } pub async fn stop(&self) -> Result<(), FfiError> { - if let Some(stop_token) = self.stop_token.lock().await.take() { - stop_token.stop().await?; - } - Ok(()) + let stop_token = self.stop_token.lock().await.take(); + self.fcx + .do_on_rt(async move { + if let Some(token) = stop_token { + token.stop().await?; + } + Ok::<(), FfiError>(()) + }) + .await } pub async fn upsert_task( diff --git a/src/daybook_ffi/repos/sync.rs b/src/daybook_ffi/repos/sync.rs index de5305a5..a83d0e00 100644 --- a/src/daybook_ffi/repos/sync.rs +++ b/src/daybook_ffi/repos/sync.rs @@ -76,16 +76,23 @@ impl SyncRepoFfi { } async fn stop(&self) -> Result<(), FfiError> { - if let Some(token) = self.sync_stop_token.lock().await.take() { - token.stop().await?; - } - if let Some(token) = self.doc_blobs_index_stop_token.lock().await.take() { - token.stop().await?; - } - if let Some(token) = self.sqlite_local_state_stop_token.lock().await.take() { - token.stop().await?; - } - Ok(()) + let sync_stop_token = self.sync_stop_token.lock().await.take(); + let doc_blobs_index_stop_token = self.doc_blobs_index_stop_token.lock().await.take(); + let sqlite_local_state_stop_token = self.sqlite_local_state_stop_token.lock().await.take(); + self.fcx + .do_on_rt(async move { + if let Some(token) = sync_stop_token { + token.stop().await?; + } + if let Some(token) = doc_blobs_index_stop_token { + token.stop().await?; + } + if let Some(token) = sqlite_local_state_stop_token { + token.stop().await?; + } + Ok::<(), FfiError>(()) + }) + .await } async fn get_ticket_url(self: Arc) -> Result { diff --git a/src/daybook_ffi/repos/tables.rs b/src/daybook_ffi/repos/tables.rs index a5b7f97a..000d0a02 100644 --- a/src/daybook_ffi/repos/tables.rs +++ b/src/daybook_ffi/repos/tables.rs @@ -45,10 +45,15 @@ impl TablesRepoFfi { } async fn stop(&self) -> Result<(), FfiError> { - if let Some(token) = self.stop_token.lock().await.take() { - token.stop().await?; - } - Ok(()) + let stop_token = self.stop_token.lock().await.take(); + self.fcx + .do_on_rt(async move { + if let Some(token) = stop_token { + token.stop().await?; + } + Ok::<(), FfiError>(()) + }) + .await } #[tracing::instrument(skip(self))] diff --git a/src/daybook_ffi/rt.rs b/src/daybook_ffi/rt.rs index 891547c0..e084de6a 100644 --- a/src/daybook_ffi/rt.rs +++ b/src/daybook_ffi/rt.rs @@ -33,6 +33,7 @@ impl RtFfi { blobs_repo: Arc, config_repo: Arc, device_id: String, + startup_progress_task_id: Option, ) -> Result, FfiError> { let cx = Arc::clone(&fcx.rcx); let repo_root = cx.layout.repo_root.to_path_buf(); @@ -42,7 +43,10 @@ impl RtFfi { let (rt, stop_token) = fcx .do_on_rt(daybook_core::rt::Rt::boot( - daybook_core::rt::RtConfig { device_id }, + daybook_core::rt::RtConfig { + device_id, + startup_progress_task_id, + }, cx.doc_app.document_id().clone(), wflow_db_url, cx.sql.db_pool.clone(), @@ -67,10 +71,15 @@ impl RtFfi { } pub async fn stop(&self) -> Result<(), FfiError> { - if let Some(token) = self.stop_token.lock().await.take() { - token.stop().await?; - } - Ok(()) + let stop_token = self.stop_token.lock().await.take(); + self.fcx + .do_on_rt(async move { + if let Some(token) = stop_token { + token.stop().await?; + } + Ok::<(), FfiError>(()) + }) + .await } pub async fn dispatch_doc_facet( diff --git a/src/utils_rs/lib.rs b/src/utils_rs/lib.rs index c30ec0d2..57fffa96 100644 --- a/src/utils_rs/lib.rs +++ b/src/utils_rs/lib.rs @@ -174,6 +174,22 @@ pub fn setup_tracing_once() { }); } +static APP_STARTUP_INSTANT: std::sync::OnceLock = std::sync::OnceLock::new(); + +pub fn init_app_startup_clock() { + let _ = APP_STARTUP_INSTANT.get_or_init(std::time::Instant::now); +} + +pub fn app_startup_elapsed() -> std::time::Duration { + APP_STARTUP_INSTANT + .get_or_init(std::time::Instant::now) + .elapsed() +} + +pub fn app_startup_elapsed_ms() -> u128 { + app_startup_elapsed().as_millis() +} + mod cheapstr { use crate::interlude::*; From 1ff1a25c660e9572469d078aafd2a680413002e8 Mon Sep 17 00:00:00 2001 From: dman-os <61868957+dman-os@users.noreply.github.com> Date: Fri, 10 Apr 2026 13:11:00 +0300 Subject: [PATCH 02/16] wip: more feature work --- .../kotlin/org/example/daybook/App.kt | 41 ++- .../daybook/DocEditorStoreViewModel.kt | 149 +++++++++++ .../example/daybook/drawer/DrawerScreen.kt | 251 ++++++------------ .../org/example/daybook/tables/Features.kt | 33 +-- .../org/example/daybook/tables/expanded.kt | 118 ++++++-- .../org/example/daybook/ui/DocEditor.kt | 71 ++++- .../ui/editor/EditorSessionController.kt | 41 ++- .../daybook/uniffi/core/daybook_core.kt | 7 - .../org/example/daybook/uniffi/daybook_ffi.kt | 63 ----- .../daybook/uniffi/types/daybook_types.kt | 1 - 10 files changed, 473 insertions(+), 302 deletions(-) create mode 100644 src/daybook_compose/composeApp/src/commonMain/kotlin/org/example/daybook/DocEditorStoreViewModel.kt diff --git a/src/daybook_compose/composeApp/src/commonMain/kotlin/org/example/daybook/App.kt b/src/daybook_compose/composeApp/src/commonMain/kotlin/org/example/daybook/App.kt index 75832752..f2888c89 100644 --- a/src/daybook_compose/composeApp/src/commonMain/kotlin/org/example/daybook/App.kt +++ b/src/daybook_compose/composeApp/src/commonMain/kotlin/org/example/daybook/App.kt @@ -97,6 +97,7 @@ import org.example.daybook.capture.data.CameraPreviewQrBridge import org.example.daybook.capture.data.CameraQrOverlayBridge import org.example.daybook.capture.screens.CaptureScreen import org.example.daybook.capture.ui.DaybookCameraViewport +import org.example.daybook.drawer.DocEditorScreen import org.example.daybook.drawer.DrawerScreen import org.example.daybook.progress.ProgressList import org.example.daybook.progress.ProgressAmountBlock @@ -192,6 +193,16 @@ val LocalContainer = error("no AppContainer provided") } +val LocalDrawerViewModel = + staticCompositionLocalOf { + error("no DrawerViewModel provided") + } + +val LocalDocEditorStore = + staticCompositionLocalOf { + error("no DocEditorStoreViewModel provided") + } + data class AppConfig(val theme: ThemeConfig = ThemeConfig.Dark) enum class AppScreens { @@ -200,7 +211,8 @@ enum class AppScreens { Tables, Progress, Settings, - Drawer + Drawer, + DocEditor } private sealed interface AppInitState { @@ -988,6 +1000,9 @@ fun App( is AppInitState.Ready -> { val appContainer = state.container + val drawerVm: DrawerViewModel = viewModel { DrawerViewModel(appContainer.drawerRepo) } + val docEditorStore: DocEditorStoreViewModel = + viewModel { DocEditorStoreViewModel(appContainer.drawerRepo) } var shutdownDone by remember(appContainer.ffiCtx) { mutableStateOf(false) } LaunchedEffect(shutdownRequested, appContainer.ffiCtx, shutdownDone) { @@ -1014,7 +1029,9 @@ fun App( Box(modifier = Modifier.fillMaxSize()) { CompositionLocalProvider( - LocalContainer provides appContainer + LocalContainer provides appContainer, + LocalDrawerViewModel provides drawerVm, + LocalDocEditorStore provides docEditorStore ) { val syncingState = cloneUiState as? CloneUiState.Syncing if (syncingState != null) { @@ -1427,8 +1444,7 @@ fun Routes( extraAction: (() -> Unit)? = null, navController: NavHostController ) { - val container = LocalContainer.current - val drawerVm: DrawerViewModel = viewModel { DrawerViewModel(container.drawerRepo) } + val drawerVm = LocalDrawerViewModel.current NavHost( startDestination = AppScreens.Home.name, @@ -1455,8 +1471,23 @@ fun Routes( ProvideChromeState(ChromeState(title = "Drawer")) { DrawerScreen( drawerVm = drawerVm, + onOpenDoc = { + navController.navigate(AppScreens.DocEditor.name) { launchSingleTop = true } + }, modifier = modifier, - contentType = contentType + ) + } + } + composable(route = AppScreens.DocEditor.name) { + ProvideChromeState( + ChromeState( + title = "Doc Editor", + onBack = { navController.popBackStack() } + ) + ) { + DocEditorScreen( + contentType = contentType, + modifier = modifier ) } } diff --git a/src/daybook_compose/composeApp/src/commonMain/kotlin/org/example/daybook/DocEditorStoreViewModel.kt b/src/daybook_compose/composeApp/src/commonMain/kotlin/org/example/daybook/DocEditorStoreViewModel.kt new file mode 100644 index 00000000..b832780b --- /dev/null +++ b/src/daybook_compose/composeApp/src/commonMain/kotlin/org/example/daybook/DocEditorStoreViewModel.kt @@ -0,0 +1,149 @@ +@file:OptIn(kotlin.time.ExperimentalTime::class) + +package org.example.daybook + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch +import kotlin.time.Clock +import kotlin.time.Duration.Companion.minutes +import org.example.daybook.ui.editor.EditorSessionController +import org.example.daybook.uniffi.DrawerEventListener +import org.example.daybook.uniffi.DrawerRepoFfi +import org.example.daybook.uniffi.FfiException +import org.example.daybook.uniffi.core.DrawerEvent +import org.example.daybook.uniffi.core.ListenerRegistration + +private data class DocEditorSessionEntry( + val controller: EditorSessionController, + var hostCount: Int = 0, + var lastTouchedMs: Long = Clock.System.now().toEpochMilliseconds() +) + +class DocEditorStoreViewModel( + private val drawerRepo: DrawerRepoFfi +) : ViewModel() { + private val sessions = mutableMapOf() + + private val _selectedDocId = MutableStateFlow(null) + val selectedDocId = _selectedDocId.asStateFlow() + + private val _selectedController = MutableStateFlow(null) + val selectedController = _selectedController.asStateFlow() + + private var listenerRegistration: ListenerRegistration? = null + private val evictionTtlMs = 10.minutes.inWholeMilliseconds + + private val listener = + object : DrawerEventListener { + override fun onDrawerEvent(event: DrawerEvent) { + when (event) { + is DrawerEvent.DocUpdated -> { + if (sessions.containsKey(event.id)) { + viewModelScope.launch { refreshDoc(event.id) } + } + } + is DrawerEvent.DocDeleted -> { + sessions.remove(event.id) + if (_selectedDocId.value == event.id) { + _selectedDocId.value = null + _selectedController.value = null + } + } + is DrawerEvent.DocAdded -> {} + } + } + } + + init { + viewModelScope.launch { + listenerRegistration = drawerRepo.ffiRegisterListener(listener) + } + viewModelScope.launch { + while (true) { + delay(30_000) + evictIdleSessions() + } + } + } + + fun selectDoc(docId: String?) { + _selectedDocId.value = docId + if (docId == null) { + _selectedController.value = null + return + } + + val entry = sessions[docId] ?: createSession(docId) + entry.lastTouchedMs = nowMs() + _selectedController.value = entry.controller + viewModelScope.launch { refreshDoc(docId) } + } + + fun attachHost(docId: String) { + val entry = sessions[docId] ?: createSession(docId) + entry.hostCount += 1 + entry.lastTouchedMs = nowMs() + } + + fun detachHost(docId: String) { + val entry = sessions[docId] ?: return + entry.hostCount = (entry.hostCount - 1).coerceAtLeast(0) + entry.lastTouchedMs = nowMs() + } + + private fun createSession(docId: String): DocEditorSessionEntry { + val controller = + EditorSessionController( + drawerRepo = drawerRepo, + scope = viewModelScope, + onDocCreated = { createdId -> selectDoc(createdId) } + ) + val entry = DocEditorSessionEntry(controller = controller) + sessions[docId] = entry + return entry + } + + private suspend fun refreshDoc(docId: String) { + val entry = sessions[docId] ?: return + try { + val bundle = drawerRepo.getBundle(docId, "main") + entry.controller.bindDoc(bundle?.doc, bundle) + entry.lastTouchedMs = nowMs() + } catch (e: FfiException) { + throw e + } + } + + private fun evictIdleSessions() { + val selectedId = _selectedDocId.value + val now = nowMs() + val toRemove = mutableListOf() + for ((docId, entry) in sessions) { + if (docId == selectedId) { + continue + } + if (entry.hostCount > 0) { + continue + } + val state = entry.controller.state.value + if (state.isDirty || state.isSaving) { + continue + } + if (now - entry.lastTouchedMs >= evictionTtlMs) { + toRemove += docId + } + } + toRemove.forEach(sessions::remove) + } + + private fun nowMs(): Long = Clock.System.now().toEpochMilliseconds() + + override fun onCleared() { + listenerRegistration?.unregister() + super.onCleared() + } +} diff --git a/src/daybook_compose/composeApp/src/commonMain/kotlin/org/example/daybook/drawer/DrawerScreen.kt b/src/daybook_compose/composeApp/src/commonMain/kotlin/org/example/daybook/drawer/DrawerScreen.kt index dced3cd7..2877f965 100644 --- a/src/daybook_compose/composeApp/src/commonMain/kotlin/org/example/daybook/drawer/DrawerScreen.kt +++ b/src/daybook_compose/composeApp/src/commonMain/kotlin/org/example/daybook/drawer/DrawerScreen.kt @@ -15,21 +15,13 @@ import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp -import androidx.lifecycle.ViewModel -import androidx.lifecycle.viewModelScope -import androidx.lifecycle.viewmodel.compose.viewModel -import kotlinx.coroutines.flow.SharingStarted -import kotlinx.coroutines.flow.map -import kotlinx.coroutines.flow.stateIn -import kotlinx.coroutines.launch import org.example.daybook.ChromeState import org.example.daybook.DaybookContentType import org.example.daybook.DocListState +import org.example.daybook.DocEditorStoreViewModel import org.example.daybook.DrawerViewModel -import org.example.daybook.LocalContainer +import org.example.daybook.LocalDocEditorStore import org.example.daybook.ProvideChromeState -import org.example.daybook.TablesState -import org.example.daybook.TablesViewModel import org.example.daybook.tables.DockableRegion import org.example.daybook.ui.DocEditor import org.example.daybook.ui.DocFacetSidebar @@ -37,193 +29,118 @@ import org.example.daybook.ui.buildSelfFacetRefUrl import org.example.daybook.ui.decodeJsonStringOrRaw import org.example.daybook.ui.decodeWellKnownFacet import org.example.daybook.ui.stripFacetRefFragment -import org.example.daybook.ui.editor.EditorSessionController import org.example.daybook.ui.editor.bodyFacetKey import org.example.daybook.ui.editor.noteFacetKey import org.example.daybook.ui.editor.titleFacetKey -import org.example.daybook.uniffi.TablesRepoFfi -import org.example.daybook.uniffi.core.* import org.example.daybook.uniffi.types.Doc import org.example.daybook.uniffi.types.FacetKey import org.example.daybook.uniffi.types.FacetTag import org.example.daybook.uniffi.types.WellKnownFacet -class DrawerScreenViewModel( - val drawerVm: DrawerViewModel, - val tablesRepo: TablesRepoFfi, - val blobsRepo: org.example.daybook.uniffi.BlobsRepoFfi, - val tablesVm: TablesViewModel -) : ViewModel() { - val editorController = - EditorSessionController( - drawerRepo = drawerVm.drawerRepo, - scope = viewModelScope, - onDocCreated = { docId -> drawerVm.selectDoc(docId) } - ) - - val listSizeExpanded = - tablesVm.tablesState - .map { state -> - if (state is TablesState.Data) { - val selectedTableId = tablesVm.selectedTableId.value - val windowId = - selectedTableId?.let { id -> - state.tables[id]?.window?.let { windowPolicy -> - when (windowPolicy) { - is TableWindow.Specific -> windowPolicy.id - is TableWindow.AllWindows -> state.windows.keys.firstOrNull() - } - } - } - windowId?.let { id -> - state.windows[id]?.documentsScreenListSizeExpanded - } ?: WindowLayoutRegionSize.Weight(0.4f) - } else { - WindowLayoutRegionSize.Weight(0.4f) +@Composable +private fun DrawerDocEditorContent( + controller: org.example.daybook.ui.editor.EditorSessionController?, + selectedDocId: String?, + modifier: Modifier = Modifier, + showFacetSidebar: Boolean, + showInlineFacetRack: Boolean = false +) { + Box(modifier = modifier.fillMaxSize()) { + if (selectedDocId != null) { + if (controller == null) { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center + ) { + CircularProgressIndicator() } - }.stateIn( - viewModelScope, - SharingStarted.WhileSubscribed(5000), - WindowLayoutRegionSize.Weight(0.4f) - ) - - fun updateListSize(weight: Float) { - viewModelScope.launch { - val state = tablesVm.tablesState.value - val selectedTableId = tablesVm.selectedTableId.value - if (state is TablesState.Data && selectedTableId != null) { - val windowId = - state.tables[selectedTableId]?.window?.let { windowPolicy -> - when (windowPolicy) { - is TableWindow.Specific -> windowPolicy.id - is TableWindow.AllWindows -> state.windows.keys.firstOrNull() - } + return@Box + } + if (showFacetSidebar) { + DockableRegion( + modifier = Modifier.fillMaxSize().padding(16.dp), + orientation = Orientation.Horizontal, + initialWeights = mapOf("doc-main" to 0.72f, "doc-facets" to 0.28f) + ) { + pane("doc-main") { + DocEditor( + controller = controller, + modifier = Modifier.fillMaxSize() + ) } - windowId?.let { id -> - state.windows[id]?.let { window -> - tablesRepo.setWindow( - id, - window.copy( - documentsScreenListSizeExpanded = WindowLayoutRegionSize.Weight( - weight - ) - ) + pane("doc-facets") { + DocFacetSidebar( + controller = controller, + modifier = Modifier.fillMaxSize() ) } } + } else { + DocEditor( + controller = controller, + showInlineFacetRack = showInlineFacetRack, + modifier = Modifier.padding(16.dp), + ) + } + } else { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center + ) { + Text("Select a document to view details") } } } - } @Composable fun DrawerScreen( drawerVm: DrawerViewModel, - contentType: DaybookContentType, + onOpenDoc: (String) -> Unit, modifier: Modifier = Modifier ) { - val container = LocalContainer.current - val tablesVm: TablesViewModel = viewModel { TablesViewModel(container.tablesRepo) } - val vm = - viewModel { - DrawerScreenViewModel(drawerVm, container.tablesRepo, container.blobsRepo, tablesVm) - } - - val selectedDocId by drawerVm.selectedDocId.collectAsState() - val selectedDoc by drawerVm.selectedDoc.collectAsState() - val selectedDocBundle by drawerVm.selectedDocBundle.collectAsState() - LaunchedEffect(selectedDoc?.id, selectedDocBundle) { - vm.editorController.bindDoc(selectedDoc, selectedDocBundle) + val docEditorStore: DocEditorStoreViewModel = LocalDocEditorStore.current + val selectedDocId by docEditorStore.selectedDocId.collectAsState() + ProvideChromeState(ChromeState(title = "Drawer")) { + DocList( + drawerViewModel = drawerVm, + selectedDocId = selectedDocId, + onDocClick = { docId -> + docEditorStore.selectDoc(docId) + onOpenDoc(docId) + }, + modifier = modifier + ) } +} - if (contentType == DaybookContentType.LIST_AND_DETAIL) { - val listSize by vm.listSizeExpanded.collectAsState() - val weight = - when (val s = listSize) { - is WindowLayoutRegionSize.Weight -> s.v1 - } - - ProvideChromeState(ChromeState(title = "Drawer")) { - DockableRegion( - modifier = modifier.fillMaxSize(), - orientation = Orientation.Horizontal, - initialWeights = mapOf("list" to weight, "editor" to 1f - weight), - onWeightsChanged = { newWeights -> - newWeights["list"]?.let { vm.updateListSize(it) } - } - ) { - pane("list") { - Box(modifier = Modifier.fillMaxSize()) { - DocList( - drawerViewModel = drawerVm, - selectedDocId = selectedDocId, - onDocClick = { drawerVm.selectDoc(it) } - ) - } - } +@Composable +fun DocEditorScreen( + contentType: DaybookContentType, + modifier: Modifier = Modifier +) { + val docEditorStore: DocEditorStoreViewModel = LocalDocEditorStore.current + val selectedDocId by docEditorStore.selectedDocId.collectAsState() + val selectedController by docEditorStore.selectedController.collectAsState() - pane("editor") { - Box(modifier = Modifier.fillMaxSize()) { - if (selectedDocId != null) { - DockableRegion( - modifier = Modifier.fillMaxSize().padding(16.dp), - orientation = Orientation.Horizontal, - initialWeights = mapOf("doc-main" to 0.72f, "doc-facets" to 0.28f) - ) { - pane("doc-main") { - DocEditor( - controller = vm.editorController, - modifier = Modifier.fillMaxSize() - ) - } - pane("doc-facets") { - DocFacetSidebar( - controller = vm.editorController, - modifier = Modifier.fillMaxSize() - ) - } - } - } else { - Box( - modifier = Modifier.fillMaxSize(), - contentAlignment = Alignment.Center - ) { - Text("Select a document to view details") - } - } - } - } - } - } - } else { + DisposableEffect(selectedDocId) { if (selectedDocId != null) { - ProvideChromeState( - ChromeState( - title = "Edit Document", - onBack = { drawerVm.selectDoc(null) } - ) - ) { - Box(modifier = modifier.fillMaxSize()) { - DocEditor( - controller = vm.editorController, - showInlineFacetRack = true, - modifier = Modifier.padding(16.dp), - ) - } - } - } else { - ProvideChromeState(ChromeState(title = "Drawer")) { - // Observe drawerState reactively - DocList( - drawerViewModel = drawerVm, - selectedDocId = null, - onDocClick = { drawerVm.selectDoc(it) }, - modifier = modifier - ) + docEditorStore.attachHost(selectedDocId!!) + } + onDispose { + if (selectedDocId != null) { + docEditorStore.detachHost(selectedDocId!!) } } } + + DrawerDocEditorContent( + controller = selectedController, + selectedDocId = selectedDocId, + modifier = modifier.fillMaxSize(), + showFacetSidebar = contentType == DaybookContentType.LIST_AND_DETAIL, + showInlineFacetRack = contentType != DaybookContentType.LIST_AND_DETAIL + ) } @Composable diff --git a/src/daybook_compose/composeApp/src/commonMain/kotlin/org/example/daybook/tables/Features.kt b/src/daybook_compose/composeApp/src/commonMain/kotlin/org/example/daybook/tables/Features.kt index b900f95f..7e39496f 100644 --- a/src/daybook_compose/composeApp/src/commonMain/kotlin/org/example/daybook/tables/Features.kt +++ b/src/daybook_compose/composeApp/src/commonMain/kotlin/org/example/daybook/tables/Features.kt @@ -12,9 +12,7 @@ import androidx.compose.material.icons.filled.Settings import androidx.compose.material.icons.filled.TableChart import androidx.compose.material3.Icon import androidx.compose.runtime.Composable -import androidx.compose.runtime.rememberCoroutineScope import androidx.navigation.NavHostController -import kotlinx.coroutines.launch import org.example.daybook.AppScreens /** @@ -22,33 +20,21 @@ import org.example.daybook.AppScreens */ @Composable fun rememberAllFeatures(navController: NavHostController): List { - val scope = rememberCoroutineScope() - return listOf( FeatureItem(FeatureKeys.Home, { Icon(Icons.Default.Home, contentDescription = "Home") }, "Home") { - scope.launch { - navController.navigate(AppScreens.Home.name) - } + navController.navigate(AppScreens.Home.name) }, FeatureItem(FeatureKeys.Capture, { Icon(Icons.Default.CameraAlt, contentDescription = "Capture") }, "Capture") { - scope.launch { - navController.navigate(AppScreens.Capture.name) - } + navController.navigate(AppScreens.Capture.name) }, FeatureItem(FeatureKeys.Drawer, { Icon(Icons.AutoMirrored.Filled.LibraryBooks, contentDescription = "Drawer") }, "Drawer") { - scope.launch { - navController.navigate(AppScreens.Drawer.name) - } + navController.navigate(AppScreens.Drawer.name) }, FeatureItem(FeatureKeys.Tables, { Icon(Icons.Default.TableChart, contentDescription = "Tables") }, "Tables") { - scope.launch { - navController.navigate(AppScreens.Tables.name) - } + navController.navigate(AppScreens.Tables.name) }, FeatureItem(FeatureKeys.Progress, { Icon(Icons.Default.Notifications, contentDescription = "Progress") }, "Progress") { - scope.launch { - navController.navigate(AppScreens.Progress.name) - } + navController.navigate(AppScreens.Progress.name) } ) } @@ -94,7 +80,6 @@ fun rememberMenuFeatures( navController: NavHostController, onShowCloneShare: () -> Unit = {} ): List { - val scope = rememberCoroutineScope() val allFeatures = rememberAllFeatures(navController) // Get the bottom bar features (Home, Capture, Documents) @@ -106,14 +91,10 @@ fun rememberMenuFeatures( return otherFeatures + listOf( FeatureItem(FeatureKeys.CloneShare, { Icon(Icons.Default.QrCode2, contentDescription = "Clone") }, "Clone") { - scope.launch { - onShowCloneShare() - } + onShowCloneShare() }, FeatureItem(FeatureKeys.Settings, { Icon(Icons.Default.Settings, contentDescription = "Settings") }, "Settings") { - scope.launch { - navController.navigate(AppScreens.Settings.name) - } + navController.navigate(AppScreens.Settings.name) } ) } diff --git a/src/daybook_compose/composeApp/src/commonMain/kotlin/org/example/daybook/tables/expanded.kt b/src/daybook_compose/composeApp/src/commonMain/kotlin/org/example/daybook/tables/expanded.kt index 69b5cb5a..f8347089 100644 --- a/src/daybook_compose/composeApp/src/commonMain/kotlin/org/example/daybook/tables/expanded.kt +++ b/src/daybook_compose/composeApp/src/commonMain/kotlin/org/example/daybook/tables/expanded.kt @@ -25,6 +25,7 @@ import androidx.compose.foundation.layout.widthIn import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight import androidx.compose.material.icons.filled.Add +import androidx.compose.material.icons.filled.FolderOpen import androidx.compose.material.icons.filled.MoreVert import androidx.compose.material.icons.filled.Menu import androidx.compose.material.icons.filled.MenuOpen @@ -74,11 +75,15 @@ import org.example.daybook.ChromeState import org.example.daybook.ChromeStateTopAppBar import org.example.daybook.ConfigViewModel import org.example.daybook.DaybookContentType +import org.example.daybook.DrawerViewModel import org.example.daybook.LocalChromeStateManager import org.example.daybook.LocalContainer +import org.example.daybook.LocalDocEditorStore +import org.example.daybook.LocalDrawerViewModel import org.example.daybook.Routes import org.example.daybook.TablesState import org.example.daybook.TablesViewModel +import org.example.daybook.drawer.DocList import org.example.daybook.progress.ProgressList import org.example.daybook.uniffi.core.WindowLayout import org.example.daybook.uniffi.core.WindowLayoutOrientation as ConfigOrientation @@ -500,6 +505,9 @@ fun SidebarContent(navController: NavHostController, modifier: Modifier = Modifi val sidebarFeatures = rememberSidebarFeatures(navController) val scope = rememberCoroutineScope() + val drawerVm: DrawerViewModel = LocalDrawerViewModel.current + val docEditorStore = LocalDocEditorStore.current + val selectedDrawerDocId by docEditorStore.selectedDocId.collectAsState() // Observe route changes to update selection highlight // Use currentBackStackEntryAsState to reactively observe route changes @@ -552,7 +560,14 @@ fun SidebarContent(navController: NavHostController, modifier: Modifier = Modifi ) { allSidebarFeatures.forEach { item -> val featureRoute = getRouteForFeature(item) - val isSelected = featureRoute != null && featureRoute == currentRoute + val isDocEditorRoute = currentRoute == AppScreens.DocEditor.name + val isSelected = + when { + featureRoute == null -> false + featureRoute == currentRoute -> true + item.key == FeatureKeys.Drawer && isDocEditorRoute -> true + else -> false + } NavigationRailItem( selected = isSelected, onClick = { @@ -570,28 +585,82 @@ fun SidebarContent(navController: NavHostController, modifier: Modifier = Modifi } } HorizontalDivider() - TabRow(selectedTabIndex = selectedSidebarPane) { - Tab( - selected = selectedSidebarPane == 0, - onClick = { selectedSidebarPane = 0 }, - text = { Text("Tabs") } - ) - Tab( - selected = selectedSidebarPane == 1, - onClick = { selectedSidebarPane = 1 }, - text = { Text("Progress") } - ) - } - when (selectedSidebarPane) { - 0 -> { - TabSelectionList( - onTabSelected = { /* TODO: Handle tab selection */ }, - modifier = Modifier.weight(1f) + Row( + modifier = Modifier.fillMaxWidth().weight(1f) + ) { + NavigationRail( + modifier = Modifier.fillMaxHeight().padding(top = 8.dp) + ) { + NavigationRailItem( + selected = selectedSidebarPane == 0, + onClick = { selectedSidebarPane = 0 }, + icon = { Icon(Icons.Default.Menu, contentDescription = "Tabs") }, + label = { Text("Tabs") }, + alwaysShowLabel = true + ) + NavigationRailItem( + selected = selectedSidebarPane == 1, + onClick = { selectedSidebarPane = 1 }, + icon = { Icon(Icons.Default.MoreVert, contentDescription = "Progress") }, + label = { Text("Progress") }, + alwaysShowLabel = true + ) + NavigationRailItem( + selected = selectedSidebarPane == 2, + onClick = { selectedSidebarPane = 2 }, + icon = { Icon(Icons.Default.FolderOpen, contentDescription = "Drawer") }, + label = { Text("Drawer") }, + alwaysShowLabel = true ) } + VerticalDivider() + Column( + modifier = Modifier.weight(1f).fillMaxHeight() + ) { + val paneTitle = + when (selectedSidebarPane) { + 0 -> "Tabs" + 1 -> "Progress" + else -> "Drawer" + } + Row( + modifier = Modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = paneTitle, + style = MaterialTheme.typography.titleSmall + ) + } + HorizontalDivider() + when (selectedSidebarPane) { + 0 -> { + TabSelectionList( + onTabSelected = { /* TODO: Handle tab selection */ }, + modifier = Modifier.weight(1f) + ) + } - else -> { - ProgressList(modifier = Modifier.weight(1f).fillMaxWidth()) + 1 -> { + ProgressList(modifier = Modifier.weight(1f).fillMaxWidth()) + } + + else -> { + DocList( + drawerViewModel = drawerVm, + selectedDocId = selectedDrawerDocId, + onDocClick = { docId -> + docEditorStore.selectDoc(docId) + if (currentRoute != AppScreens.DocEditor.name) { + navController.navigate(AppScreens.DocEditor.name) { + launchSingleTop = true + } + } + }, + modifier = Modifier.weight(1f).fillMaxWidth() + ) + } + } } } } @@ -601,7 +670,14 @@ fun SidebarContent(navController: NavHostController, modifier: Modifier = Modifi NavigationRail(modifier = Modifier.fillMaxHeight()) { allSidebarFeatures.forEach { item -> val featureRoute = getRouteForFeature(item) - val isSelected = featureRoute != null && featureRoute == currentRoute + val isDocEditorRoute = currentRoute == AppScreens.DocEditor.name + val isSelected = + when { + featureRoute == null -> false + featureRoute == currentRoute -> true + item.key == FeatureKeys.Drawer && isDocEditorRoute -> true + else -> false + } NavigationRailItem( selected = isSelected, diff --git a/src/daybook_compose/composeApp/src/commonMain/kotlin/org/example/daybook/ui/DocEditor.kt b/src/daybook_compose/composeApp/src/commonMain/kotlin/org/example/daybook/ui/DocEditor.kt index 436a3863..28ec09d6 100644 --- a/src/daybook_compose/composeApp/src/commonMain/kotlin/org/example/daybook/ui/DocEditor.kt +++ b/src/daybook_compose/composeApp/src/commonMain/kotlin/org/example/daybook/ui/DocEditor.kt @@ -26,7 +26,10 @@ import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.ArrowDownward import androidx.compose.material.icons.filled.ArrowUpward +import androidx.compose.material.icons.filled.CheckCircle +import androidx.compose.material.icons.filled.Error import androidx.compose.material.icons.filled.MoreVert +import androidx.compose.material.icons.filled.Sync import androidx.compose.material.icons.filled.Star import androidx.compose.material.icons.outlined.StarOutline import androidx.compose.material3.DropdownMenu @@ -60,6 +63,8 @@ import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.unit.dp import androidx.compose.ui.draw.clip import coil3.compose.AsyncImage @@ -74,6 +79,18 @@ import org.example.daybook.uniffi.types.Doc import org.example.daybook.uniffi.types.FacetKey import org.example.daybook.uniffi.types.WellKnownFacet +private enum class EditorSaveStatus { + Idle, + Saving, + Error +} + +private data class SaveStatusUi( + val icon: ImageVector, + val tint: Color, + val label: String, +) + @Composable fun DocEditor( controller: EditorSessionController, @@ -83,6 +100,12 @@ fun DocEditor( val state by controller.state.collectAsState() val snackbarHostState = remember { SnackbarHostState() } var uiMessage by remember { mutableStateOf(null) } + val saveStatus = + when { + state.saveError != null -> EditorSaveStatus.Error + state.isSaving -> EditorSaveStatus.Saving + else -> EditorSaveStatus.Idle + } LaunchedEffect(state.saveError) { val errorMessage = state.saveError ?: return@LaunchedEffect @@ -146,6 +169,7 @@ fun DocEditor( descriptor = descriptor, doc = state.doc, controller = controller, + saveStatus = saveStatus, modifier = Modifier.bringIntoViewRequester(bringIntoViewRequester), canShowMenu = state.docId != null, noteDraft = state.noteEditors[descriptor.facetKey]?.draft, @@ -161,15 +185,6 @@ fun DocEditor( } } - if (state.isSaving) { - Text( - text = "Saving…", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.padding(top = 8.dp), - ) - } - if (showInlineFacetRack) { HorizontalDivider(modifier = Modifier.padding(vertical = 8.dp)) Text( @@ -199,6 +214,7 @@ private fun FacetListItem( descriptor: FacetViewDescriptor, doc: Doc?, controller: EditorSessionController, + saveStatus: EditorSaveStatus, modifier: Modifier = Modifier, canShowMenu: Boolean, noteDraft: String?, @@ -212,6 +228,7 @@ private fun FacetListItem( Column(modifier = modifier.fillMaxWidth()) { FacetHeader( descriptor = descriptor, + saveStatus = saveStatus, canShowMenu = canShowMenu, onAddNote = { controller.addNoteFacetAfter(descriptor.facetKey) }, onMakePrimary = { controller.makeFacetPrimary(descriptor.facetKey) }, @@ -267,9 +284,11 @@ private fun FacetListItem( } } +@OptIn(ExperimentalMaterial3Api::class) @Composable private fun FacetHeader( descriptor: FacetViewDescriptor, + saveStatus: EditorSaveStatus, canShowMenu: Boolean, onAddNote: () -> Unit, onMakePrimary: () -> Unit, @@ -278,6 +297,22 @@ private fun FacetHeader( canMoveUp: Boolean, canMoveDown: Boolean, ) { + val saveStatusUi = + when (saveStatus) { + EditorSaveStatus.Idle -> null + EditorSaveStatus.Saving -> + SaveStatusUi( + icon = Icons.Filled.Sync, + tint = MaterialTheme.colorScheme.primary, + label = "Saving" + ) + EditorSaveStatus.Error -> + SaveStatusUi( + icon = Icons.Filled.Error, + tint = MaterialTheme.colorScheme.error, + label = "Save failed" + ) + } val interactionSource = remember { MutableInteractionSource() } val isHovered by interactionSource.collectIsHoveredAsState() Row( @@ -295,6 +330,24 @@ private fun FacetHeader( Row( verticalAlignment = Alignment.CenterVertically, ) { + if (saveStatusUi != null) { + TooltipBox( + positionProvider = TooltipDefaults.rememberPlainTooltipPositionProvider(), + tooltip = { + PlainTooltip { + Text(saveStatusUi.label) + } + }, + state = rememberTooltipState(), + ) { + Icon( + imageVector = saveStatusUi.icon, + contentDescription = saveStatusUi.label, + tint = saveStatusUi.tint, + ) + } + Spacer(modifier = Modifier.width(4.dp)) + } if (showPriorityActions) { FacetActionIconButton( label = if (descriptor.isPrimary) "Facet is primary" else "Make this facet primary", diff --git a/src/daybook_compose/composeApp/src/commonMain/kotlin/org/example/daybook/ui/editor/EditorSessionController.kt b/src/daybook_compose/composeApp/src/commonMain/kotlin/org/example/daybook/ui/editor/EditorSessionController.kt index e5fd2ea8..581b1090 100644 --- a/src/daybook_compose/composeApp/src/commonMain/kotlin/org/example/daybook/ui/editor/EditorSessionController.kt +++ b/src/daybook_compose/composeApp/src/commonMain/kotlin/org/example/daybook/ui/editor/EditorSessionController.kt @@ -3,6 +3,7 @@ package org.example.daybook.ui.editor import kotlin.uuid.Uuid +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Job import kotlinx.coroutines.delay @@ -106,7 +107,22 @@ class EditorSessionController( private var saveDebounceJob: Job? = null private var nextScrollRequestSeq: Long = 1 - fun bindDoc(doc: Doc?, bundle: DocBundle? = null) { + fun bindDoc( + doc: Doc?, + bundle: DocBundle? = null, + allowOverwriteDirtySameDoc: Boolean = false, + ) { + val currentState = _state.value + val incomingDocId = doc?.id + if ( + !allowOverwriteDirtySameDoc && + currentState.isDirty && + currentState.docId != null && + currentState.docId == incomingDocId + ) { + return + } + saveDebounceJob?.cancel() persistedDocSnapshot = doc facetHeadsByKeyString = bundle?.facetHeadsByKey?.mapKeys { (facetKey, _) -> facetKeyRefPathString(facetKey) } ?: emptyMap() mainBranchHeads = bundle?.branchHeads.orEmpty() @@ -207,6 +223,7 @@ class EditorSessionController( if (!snapshot.isDirty) { return } + val snapshotDraft = draftFingerprint(snapshot) _state.update { it.copy(isSaving = true, saveError = null) } try { @@ -239,7 +256,7 @@ class EditorSessionController( ) onDocCreated?.invoke(addedId) val bundle = drawerRepo.getBundle(addedId, "main") - bindDoc(bundle?.doc, bundle) + bindDoc(bundle?.doc, bundle, allowOverwriteDirtySameDoc = true) return } @@ -249,12 +266,30 @@ class EditorSessionController( drawerRepo.updateBatch(listOf(UpdateDocArgsV2("main", null, patch))) } val bundle = drawerRepo.getBundle(currentDocId, "main") - bindDoc(bundle?.doc, bundle) + val current = _state.value + val sameDoc = current.docId == snapshot.docId + val noNewerDraftChanges = draftFingerprint(current) == snapshotDraft + if (sameDoc && noNewerDraftChanges) { + bindDoc(bundle?.doc, bundle, allowOverwriteDirtySameDoc = true) + } else if (sameDoc) { + _state.update { it.copy(isSaving = false) } + } } catch (error: Throwable) { + if (error is CancellationException) { + throw error + } _state.update { it.copy(isSaving = false, saveError = error.message ?: "Failed to save") } } } + private fun draftFingerprint(state: EditorSessionState): Pair> { + val notes = + state.noteEditors + .mapKeys { (facetKey, _) -> facetKeyRefPathString(facetKey) } + .mapValues { (_, editorState) -> editorState.draft } + return state.titleDraft to notes + } + private fun buildBoundState(doc: Doc?): EditorSessionState { val titleRawValue = doc?.facets?.get(titleFacetKey()) val (nextTitle, titleEditable, titleNotice) = diff --git a/src/daybook_compose/composeApp/src/commonMain/kotlin/org/example/daybook/uniffi/core/daybook_core.kt b/src/daybook_compose/composeApp/src/commonMain/kotlin/org/example/daybook/uniffi/core/daybook_core.kt index 0e882c7d..b3af8aa9 100644 --- a/src/daybook_compose/composeApp/src/commonMain/kotlin/org/example/daybook/uniffi/core/daybook_core.kt +++ b/src/daybook_compose/composeApp/src/commonMain/kotlin/org/example/daybook/uniffi/core/daybook_core.kt @@ -5815,10 +5815,3 @@ public object FfiConverterTypeUuid: FfiConverter { */ public typealias VersionTag = kotlin.String public typealias FfiConverterTypeVersionTag = FfiConverterString - - - - - - - diff --git a/src/daybook_compose/composeApp/src/commonMain/kotlin/org/example/daybook/uniffi/daybook_ffi.kt b/src/daybook_compose/composeApp/src/commonMain/kotlin/org/example/daybook/uniffi/daybook_ffi.kt index 6dd8c03a..9a8ed980 100644 --- a/src/daybook_compose/composeApp/src/commonMain/kotlin/org/example/daybook/uniffi/daybook_ffi.kt +++ b/src/daybook_compose/composeApp/src/commonMain/kotlin/org/example/daybook/uniffi/daybook_ffi.kt @@ -11528,66 +11528,3 @@ public object FfiConverterTypeUuid: FfiConverter { FfiConverterByteArray.write(builtinValue, buf) } } - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/src/daybook_compose/composeApp/src/commonMain/kotlin/org/example/daybook/uniffi/types/daybook_types.kt b/src/daybook_compose/composeApp/src/commonMain/kotlin/org/example/daybook/uniffi/types/daybook_types.kt index 6ce412db..e832dddd 100644 --- a/src/daybook_compose/composeApp/src/commonMain/kotlin/org/example/daybook/uniffi/types/daybook_types.kt +++ b/src/daybook_compose/composeApp/src/commonMain/kotlin/org/example/daybook/uniffi/types/daybook_types.kt @@ -3243,4 +3243,3 @@ public object FfiConverterTypeUuid: FfiConverter { FfiConverterByteArray.write(builtinValue, buf) } } - From 86cc611ba455c441a6dcc45add2f094ce923d2bd Mon Sep 17 00:00:00 2001 From: dman-os <61868957+dman-os@users.noreply.github.com> Date: Sat, 11 Apr 2026 08:38:24 +0300 Subject: [PATCH 03/16] wip: nicer --- src/am_utils_rs/repo.rs | 3 ++ .../kotlin/org/example/daybook/App.kt | 30 ++++++++++++++++-- .../example/daybook/drawer/DrawerScreen.kt | 22 ++++++++----- .../org/example/daybook/tables/compact.kt | 31 ++++++++++++++----- .../kotlin/org/example/daybook/main.kt | 19 +++++++++--- src/daybook_core/app.rs | 6 +++- src/daybook_core/local_state.rs | 6 +++- src/daybook_core/repo.rs | 2 ++ 8 files changed, 96 insertions(+), 23 deletions(-) diff --git a/src/am_utils_rs/repo.rs b/src/am_utils_rs/repo.rs index cc4428d5..46fda51f 100644 --- a/src/am_utils_rs/repo.rs +++ b/src/am_utils_rs/repo.rs @@ -26,6 +26,8 @@ pub use changes::{ }; pub use samod_core::ChangeOrigin as BigRepoChangeOrigin; +const SQLITE_POOL_ACQUIRE_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(90); + #[derive(Debug, Clone)] pub struct BigRepoConfig { pub sqlite_url: String, @@ -151,6 +153,7 @@ impl BigRepo { .create_if_missing(true); let state_pool = sqlx::sqlite::SqlitePoolOptions::new() .max_connections(1) + .acquire_timeout(SQLITE_POOL_ACQUIRE_TIMEOUT) .connect_with(connect_options) .await .wrap_err("failed connecting big repo sqlite")?; diff --git a/src/daybook_compose/composeApp/src/commonMain/kotlin/org/example/daybook/App.kt b/src/daybook_compose/composeApp/src/commonMain/kotlin/org/example/daybook/App.kt index f2888c89..08f27598 100644 --- a/src/daybook_compose/composeApp/src/commonMain/kotlin/org/example/daybook/App.kt +++ b/src/daybook_compose/composeApp/src/commonMain/kotlin/org/example/daybook/App.kt @@ -5,6 +5,10 @@ package org.example.daybook import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.EnterTransition +import androidx.compose.animation.ExitTransition +import androidx.compose.animation.slideInHorizontally +import androidx.compose.animation.slideOutHorizontally import androidx.compose.animation.core.FastOutSlowInEasing import androidx.compose.animation.core.RepeatMode import androidx.compose.animation.core.animateFloat @@ -1467,7 +1471,13 @@ fun Routes( SettingsScreen(modifier = modifier) } } - composable(route = AppScreens.Drawer.name) { + composable( + route = AppScreens.Drawer.name, + enterTransition = { EnterTransition.None }, + exitTransition = { ExitTransition.None }, + popEnterTransition = { EnterTransition.None }, + popExitTransition = { ExitTransition.None } + ) { ProvideChromeState(ChromeState(title = "Drawer")) { DrawerScreen( drawerVm = drawerVm, @@ -1478,7 +1488,23 @@ fun Routes( ) } } - composable(route = AppScreens.DocEditor.name) { + composable( + route = AppScreens.DocEditor.name, + enterTransition = { + slideInHorizontally( + animationSpec = tween(240), + initialOffsetX = { fullWidth -> fullWidth } + ) + }, + exitTransition = { ExitTransition.None }, + popEnterTransition = { EnterTransition.None }, + popExitTransition = { + slideOutHorizontally( + animationSpec = tween(240), + targetOffsetX = { fullWidth -> fullWidth } + ) + } + ) { ProvideChromeState( ChromeState( title = "Doc Editor", diff --git a/src/daybook_compose/composeApp/src/commonMain/kotlin/org/example/daybook/drawer/DrawerScreen.kt b/src/daybook_compose/composeApp/src/commonMain/kotlin/org/example/daybook/drawer/DrawerScreen.kt index 2877f965..45cbee29 100644 --- a/src/daybook_compose/composeApp/src/commonMain/kotlin/org/example/daybook/drawer/DrawerScreen.kt +++ b/src/daybook_compose/composeApp/src/commonMain/kotlin/org/example/daybook/drawer/DrawerScreen.kt @@ -10,6 +10,7 @@ import androidx.compose.foundation.gestures.Orientation import androidx.compose.foundation.layout.* import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.background import androidx.compose.material3.* import androidx.compose.runtime.* import androidx.compose.ui.Alignment @@ -134,13 +135,20 @@ fun DocEditorScreen( } } - DrawerDocEditorContent( - controller = selectedController, - selectedDocId = selectedDocId, - modifier = modifier.fillMaxSize(), - showFacetSidebar = contentType == DaybookContentType.LIST_AND_DETAIL, - showInlineFacetRack = contentType != DaybookContentType.LIST_AND_DETAIL - ) + Box( + modifier = + modifier + .fillMaxSize() + .background(MaterialTheme.colorScheme.surface) + ) { + DrawerDocEditorContent( + controller = selectedController, + selectedDocId = selectedDocId, + modifier = Modifier.fillMaxSize(), + showFacetSidebar = contentType == DaybookContentType.LIST_AND_DETAIL, + showInlineFacetRack = contentType != DaybookContentType.LIST_AND_DETAIL + ) + } } @Composable diff --git a/src/daybook_compose/composeApp/src/commonMain/kotlin/org/example/daybook/tables/compact.kt b/src/daybook_compose/composeApp/src/commonMain/kotlin/org/example/daybook/tables/compact.kt index 7ab78e76..7257018d 100644 --- a/src/daybook_compose/composeApp/src/commonMain/kotlin/org/example/daybook/tables/compact.kt +++ b/src/daybook_compose/composeApp/src/commonMain/kotlin/org/example/daybook/tables/compact.kt @@ -89,9 +89,11 @@ import androidx.compose.ui.unit.sp import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewmodel.compose.viewModel import androidx.navigation.NavHostController +import androidx.navigation.compose.currentBackStackEntryAsState import kotlinx.coroutines.Job import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.launch +import org.example.daybook.AppScreens import org.example.daybook.ChromeState import org.example.daybook.ChromeStateTopAppBar import org.example.daybook.ConfigViewModel @@ -271,6 +273,9 @@ fun CompactLayout( val featureControllers = navBarFeatureControllers + prominentButtonControllers val featureReadyStates = featureControllers.map { it.ready.collectAsState() } var menuGestureSurfaceWindowRect by remember { mutableStateOf(null) } + val navBackStackEntry by navController.currentBackStackEntryAsState() + val currentRoute = navBackStackEntry?.destination?.route + val isDocEditorFullscreen = currentRoute == AppScreens.DocEditor.name val menuGestureModifier = Modifier @@ -415,6 +420,14 @@ fun CompactLayout( val tablesRepo = LocalContainer.current.tablesRepo val vm = viewModel { TablesViewModel(tablesRepo) } + LaunchedEffect(isDocEditorFullscreen) { + if (isDocEditorFullscreen) { + revealSheetState.hide() + leftDrawerState.close() + showFeaturesMenu = false + } + } + // Clear cached tab layout rects whenever the selected table or sheet content changes // FIXME: LaunchedEffect(vm.tablesState.collectAsState(), sheetContent) { @@ -495,14 +508,16 @@ fun CompactLayout( Scaffold( modifier = modifier, bottomBar = { - DaybookBottomNavigationBar( - centerContent = { - centerNavBarContent() - }, - // showLeftDrawerHint = leftDrawerState.currentValue == DrawerValue.Closed && !isLeftDrawerDragging, - showLeftDrawerHint = true, - bottomBarModifier = menuGestureModifier, - ) + if (!isDocEditorFullscreen) { + DaybookBottomNavigationBar( + centerContent = { + centerNavBarContent() + }, + // showLeftDrawerHint = leftDrawerState.currentValue == DrawerValue.Closed && !isLeftDrawerDragging, + showLeftDrawerHint = true, + bottomBarModifier = menuGestureModifier, + ) + } }, snackbarHost = { SnackbarHost(snackbarHostState) } ) { scaffoldPadding -> diff --git a/src/daybook_compose/composeApp/src/desktopMain/kotlin/org/example/daybook/main.kt b/src/daybook_compose/composeApp/src/desktopMain/kotlin/org/example/daybook/main.kt index dfd125f7..29a0e86a 100644 --- a/src/daybook_compose/composeApp/src/desktopMain/kotlin/org/example/daybook/main.kt +++ b/src/daybook_compose/composeApp/src/desktopMain/kotlin/org/example/daybook/main.kt @@ -3,6 +3,10 @@ package org.example.daybook import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.DisposableEffect 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.platform.LocalDensity import androidx.compose.ui.unit.Density import androidx.compose.ui.unit.DpSize @@ -52,18 +56,20 @@ fun main() = application { Window( onCloseRequest = { println("[APP_SHUTDOWN] start: close requested, beginning graceful shutdown") - exitApplication() + signalShutdownRequested.set(true) }, title = "Daybook", state = windowState ) { + var shutdownRequested by remember { mutableStateOf(false) } + var shutdownDone by remember { mutableStateOf(false) } LaunchedEffect(Unit) { while (true) { if (signalShutdownRequested.getAndSet(false)) { println("[APP_SHUTDOWN] start: signal-triggered graceful shutdown") - EventQueue.invokeLater { exitApplication() } - break + shutdownRequested = true } + if (shutdownDone) break delay(100) } } @@ -87,7 +93,12 @@ fun main() = application { App( extraAction = { }, - autoShutdownOnDispose = true + shutdownRequested = shutdownRequested, + onShutdownCompleted = { + shutdownDone = true + EventQueue.invokeLater { exitApplication() } + }, + autoShutdownOnDispose = false ) } } diff --git a/src/daybook_core/app.rs b/src/daybook_core/app.rs index 66db7deb..60a09ed7 100644 --- a/src/daybook_core/app.rs +++ b/src/daybook_core/app.rs @@ -4,6 +4,9 @@ use sqlx::sqlite::{SqliteConnectOptions, SqliteJournalMode, SqlitePoolOptions}; use sqlx::SqlitePool; use std::str::FromStr; +const SQLITE_POOL_ACQUIRE_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(90); +const SQLITE_BUSY_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(90); + pub struct SqlCtx { pub db_pool: SqlitePool, } @@ -26,10 +29,11 @@ impl SqlCtx { } let db_pool = SqlitePoolOptions::new() + .acquire_timeout(SQLITE_POOL_ACQUIRE_TIMEOUT) .connect_with( SqliteConnectOptions::from_str(database_url)? .journal_mode(SqliteJournalMode::Wal) - .busy_timeout(std::time::Duration::from_secs(5)) + .busy_timeout(SQLITE_BUSY_TIMEOUT) .create_if_missing(true), ) .await diff --git a/src/daybook_core/local_state.rs b/src/daybook_core/local_state.rs index 28246bbc..f1f589b2 100644 --- a/src/daybook_core/local_state.rs +++ b/src/daybook_core/local_state.rs @@ -3,6 +3,9 @@ use sqlx::sqlite::{SqliteConnectOptions, SqliteJournalMode, SqlitePoolOptions}; use std::str::FromStr; use tokio_util::sync::CancellationToken; +const SQLITE_POOL_ACQUIRE_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(90); +const SQLITE_BUSY_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(90); + #[derive(Debug, Clone)] pub enum LocalStateEvent { ListChanged, @@ -118,10 +121,11 @@ impl SqliteLocalStateRepo { let connect_options = SqliteConnectOptions::from_str(&format!("sqlite://{sqlite_file_path}"))? .journal_mode(SqliteJournalMode::Wal) - .busy_timeout(std::time::Duration::from_secs(5)) + .busy_timeout(SQLITE_BUSY_TIMEOUT) .create_if_missing(true); let db_pool = SqlitePoolOptions::new() .max_connections(1) + .acquire_timeout(SQLITE_POOL_ACQUIRE_TIMEOUT) .connect_with(connect_options) .await .wrap_err("error initializing sqlite local state connection")?; diff --git a/src/daybook_core/repo.rs b/src/daybook_core/repo.rs index 89c435bf..e758d1a9 100644 --- a/src/daybook_core/repo.rs +++ b/src/daybook_core/repo.rs @@ -13,6 +13,7 @@ use tokio_util::sync::CancellationToken; const REPO_MARKER_FILE: &str = "db.repo.txt"; const REPO_USER_ID_KEY: &str = "repo.user_id"; +const SQLITE_POOL_ACQUIRE_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(90); #[derive(Debug, Clone, PartialEq, Eq)] pub struct RepoLayout { @@ -234,6 +235,7 @@ impl RepoCtx { .create_if_missing(true); let partition_pool = sqlx::sqlite::SqlitePoolOptions::new() .max_connections(1) + .acquire_timeout(SQLITE_POOL_ACQUIRE_TIMEOUT) .connect_with(connect_options) .await .wrap_err("failed connecting big repo sqlite")?; From 7b6331184ccd18ae94dd1689595cea361aede613 Mon Sep 17 00:00:00 2001 From: dman-os <61868957+dman-os@users.noreply.github.com> Date: Sat, 11 Apr 2026 10:19:28 +0300 Subject: [PATCH 04/16] fix: android build --- .../composeApp/build.gradle.kts | 70 ++++++++----------- .../kotlin/org/example/daybook/App.kt | 9 ++- .../daybook/uniffi/core/daybook_core.kt | 7 ++ .../org/example/daybook/uniffi/daybook_ffi.kt | 63 +++++++++++++++++ .../daybook/uniffi/types/daybook_types.kt | 1 + .../org/example/daybook/welcome/welcome.kt | 58 +++++++++++---- x/build-a-dayb.ts | 7 +- 7 files changed, 160 insertions(+), 55 deletions(-) diff --git a/src/daybook_compose/composeApp/build.gradle.kts b/src/daybook_compose/composeApp/build.gradle.kts index 78bba22d..77fadd6e 100644 --- a/src/daybook_compose/composeApp/build.gradle.kts +++ b/src/daybook_compose/composeApp/build.gradle.kts @@ -6,6 +6,7 @@ import org.jetbrains.kotlin.gradle.targets.js.webpack.KotlinWebpackConfig import org.gradle.api.tasks.Copy import org.gradle.api.tasks.Delete import org.gradle.api.tasks.Exec +import org.gradle.api.tasks.Sync import org.gradle.process.CommandLineArgumentProvider import java.io.File @@ -385,55 +386,46 @@ fun registerRustAndroidCopyTask( ) = run { val destDir = File(project.projectDir, "src/androidMain/jniLibs/$targetAbi") - val destSoFile = File(destDir, "libdaybook_ffi.so") - val destLibcxxFile = File(destDir, "libc++_shared.so") - - tasks.register(taskName) { - group = "build" - description = "Copy Rust daybook_ffi and libc++_shared.so to Android jniLibs" - - dependsOn(buildTaskName) - - val sourceSoFile = File(cargoTargetDir, sourceLibPath.removePrefix("target/")) - val ortLibDir = ortLibLocation?.let(::File) - val ortSharedLibs = - if (ortPreferDynamicLink && ortLibDir != null && ortLibDir.exists()) { - ortLibDir - .listFiles() - ?.filter { it.isFile && it.name.contains(".so") } - ?.toList() - ?: emptyList() - } else { - emptyList() - } - val androidNdkRoot = System.getenv("ANDROID_NDK_ROOT") - val libcxxSourceFile = - if (!androidNdkRoot.isNullOrBlank()) ndkLibCppSharedForAbi(targetAbi, androidNdkRoot) else null - doFirst { + tasks.register(taskName) { + group = "build" + description = "Sync Rust daybook_ffi and native Android runtime libs to jniLibs" + + dependsOn(buildTaskName) + into(destDir) + + val sourceSoFile = File(cargoTargetDir, sourceLibPath.removePrefix("target/")) if (!sourceSoFile.exists()) { throw GradleException("Missing Rust Android library: ${sourceSoFile.absolutePath}") } - destDir.mkdirs() - destSoFile.delete() - destLibcxxFile.delete() + from(sourceSoFile) + + val androidNdkRoot = System.getenv("ANDROID_NDK_ROOT") + val libcxxSourceFile = + if (!androidNdkRoot.isNullOrBlank()) ndkLibCppSharedForAbi(targetAbi, androidNdkRoot) else null + if (libcxxSourceFile != null && libcxxSourceFile.exists()) { + from(libcxxSourceFile) + } + + val ortLibDir = ortLibLocation?.let(::File) + val ortSharedLibs = + if (ortPreferDynamicLink && ortLibDir != null && ortLibDir.exists()) { + ortLibDir + .listFiles() + ?.filter { it.isFile && it.name.contains(".so") } + ?.toList() + ?: emptyList() + } else { + emptyList() + } if (ortSharedLibs.isNotEmpty()) { logger.lifecycle( - "Copying ${ortSharedLibs.size} ORT shared libraries from ${ortLibDir?.absolutePath} (profile=${ortLibProfile ?: "unknown"})" + "Syncing ${ortSharedLibs.size} ORT shared libraries from ${ortLibDir?.absolutePath} (profile=${ortLibProfile ?: "unknown"})" ) + from(ortSharedLibs) } } - - from(sourceSoFile) - if (libcxxSourceFile != null && libcxxSourceFile.exists()) { - from(libcxxSourceFile) - } - if (ortSharedLibs.isNotEmpty()) { - from(ortSharedLibs) - } - into(destDir) } -} // Debug variant: build Rust in debug mode tasks.register("buildRustAndroidDebug") { diff --git a/src/daybook_compose/composeApp/src/commonMain/kotlin/org/example/daybook/App.kt b/src/daybook_compose/composeApp/src/commonMain/kotlin/org/example/daybook/App.kt index 08f27598..99157afd 100644 --- a/src/daybook_compose/composeApp/src/commonMain/kotlin/org/example/daybook/App.kt +++ b/src/daybook_compose/composeApp/src/commonMain/kotlin/org/example/daybook/App.kt @@ -1150,6 +1150,7 @@ fun App( private suspend fun shutdownAppContainer(appContainer: AppContainer) { println("[APP_SHUTDOWN] flushing to disk: begin") + val rtFfi = appContainer.rtFfi val syncRepo = appContainer.syncRepo if (syncRepo != null) { withContext(Dispatchers.IO) { @@ -1157,6 +1158,12 @@ private suspend fun shutdownAppContainer(appContainer: AppContainer) { syncRepo.stop() } } + if (rtFfi != null) { + withContext(Dispatchers.IO) { + println("[APP_SHUTDOWN] flushing to disk: stopping runtime repo") + rtFfi.stop() + } + } withContext(Dispatchers.IO) { println("[APP_SHUTDOWN] flushing to disk: stopping progress repo") appContainer.progressRepo.stop() @@ -1166,7 +1173,7 @@ private suspend fun shutdownAppContainer(appContainer: AppContainer) { appContainer.tablesRepo.close() appContainer.dispatchRepo.close() appContainer.progressRepo.close() - appContainer.rtFfi?.close() + rtFfi?.close() appContainer.plugsRepo.close() appContainer.configRepo.close() appContainer.blobsRepo.close() diff --git a/src/daybook_compose/composeApp/src/commonMain/kotlin/org/example/daybook/uniffi/core/daybook_core.kt b/src/daybook_compose/composeApp/src/commonMain/kotlin/org/example/daybook/uniffi/core/daybook_core.kt index b3af8aa9..0e882c7d 100644 --- a/src/daybook_compose/composeApp/src/commonMain/kotlin/org/example/daybook/uniffi/core/daybook_core.kt +++ b/src/daybook_compose/composeApp/src/commonMain/kotlin/org/example/daybook/uniffi/core/daybook_core.kt @@ -5815,3 +5815,10 @@ public object FfiConverterTypeUuid: FfiConverter { */ public typealias VersionTag = kotlin.String public typealias FfiConverterTypeVersionTag = FfiConverterString + + + + + + + diff --git a/src/daybook_compose/composeApp/src/commonMain/kotlin/org/example/daybook/uniffi/daybook_ffi.kt b/src/daybook_compose/composeApp/src/commonMain/kotlin/org/example/daybook/uniffi/daybook_ffi.kt index 9a8ed980..6dd8c03a 100644 --- a/src/daybook_compose/composeApp/src/commonMain/kotlin/org/example/daybook/uniffi/daybook_ffi.kt +++ b/src/daybook_compose/composeApp/src/commonMain/kotlin/org/example/daybook/uniffi/daybook_ffi.kt @@ -11528,3 +11528,66 @@ public object FfiConverterTypeUuid: FfiConverter { FfiConverterByteArray.write(builtinValue, buf) } } + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/daybook_compose/composeApp/src/commonMain/kotlin/org/example/daybook/uniffi/types/daybook_types.kt b/src/daybook_compose/composeApp/src/commonMain/kotlin/org/example/daybook/uniffi/types/daybook_types.kt index e832dddd..6ce412db 100644 --- a/src/daybook_compose/composeApp/src/commonMain/kotlin/org/example/daybook/uniffi/types/daybook_types.kt +++ b/src/daybook_compose/composeApp/src/commonMain/kotlin/org/example/daybook/uniffi/types/daybook_types.kt @@ -3243,3 +3243,4 @@ public object FfiConverterTypeUuid: FfiConverter { FfiConverterByteArray.write(builtinValue, buf) } } + diff --git a/src/daybook_compose/composeApp/src/commonMain/kotlin/org/example/daybook/welcome/welcome.kt b/src/daybook_compose/composeApp/src/commonMain/kotlin/org/example/daybook/welcome/welcome.kt index 79c26402..87efc6b8 100644 --- a/src/daybook_compose/composeApp/src/commonMain/kotlin/org/example/daybook/welcome/welcome.kt +++ b/src/daybook_compose/composeApp/src/commonMain/kotlin/org/example/daybook/welcome/welcome.kt @@ -335,19 +335,52 @@ fun WelcomeFlowNavHost( } composable(WelcomeRoute.CreateRepo) { - val editState = - (createRepoUiState as? CreateRepoUiState.Editing) - ?: CreateRepoUiState.Editing(repoName = "daybook-repo") + val editState = createRepoUiState as? CreateRepoUiState.Editing fun updateCreateState( transform: (CreateRepoUiState.Editing) -> CreateRepoUiState.Editing ) { val current = createRepoUiState as? CreateRepoUiState.Editing ?: return onCreateRepoUiStateChange(transform(current)) } - if (createRepoUiState !is CreateRepoUiState.Editing) { + + if (editState == null) { + Surface( + modifier = Modifier.fillMaxSize(), + color = MaterialTheme.colorScheme.background + ) { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center + ) { + CircularProgressIndicator() + } + } LaunchedEffect(Unit) { - onCreateRepoUiStateChange(editState) + val repoName = "daybook-repo" + try { + val defaultParent = withAppFfiCtx { gcx -> + gcx.defaultCloneParentDir().trim() + } + onCreateRepoUiStateChange( + CreateRepoUiState.Editing( + repoName = repoName, + parentPath = defaultParent, + isCreating = false + ) + ) + } catch (error: Throwable) { + if (error is CancellationException) throw error + onCreateRepoUiStateChange( + CreateRepoUiState.Editing( + repoName = repoName, + parentPath = "", + isCreating = false, + errorMessage = "Failed loading default parent: ${describeThrowable(error)}" + ) + ) + } } + return@composable } CreateRepoScreen( @@ -384,21 +417,20 @@ fun WelcomeFlowNavHost( } ) - if (editState.parentPath.isBlank()) { - LaunchedEffect(Unit) { + if (editState.parentPath.isBlank() && !editState.isCreating) { + LaunchedEffect(editState.parentPath, editState.isCreating) { try { val defaultParent = withAppFfiCtx { gcx -> gcx.defaultCloneParentDir().trim() } - updateCreateState { current -> - if (!current.parentPath.isBlank()) { - current - } else { - current.copy( + val latest = createRepoUiState as? CreateRepoUiState.Editing ?: return@LaunchedEffect + if (latest.parentPath.isBlank()) { + onCreateRepoUiStateChange( + latest.copy( parentPath = defaultParent, errorMessage = null ) - } + ) } } catch (error: Throwable) { if (error is CancellationException) throw error diff --git a/x/build-a-dayb.ts b/x/build-a-dayb.ts index 86094fbe..c1a1dd0e 100755 --- a/x/build-a-dayb.ts +++ b/x/build-a-dayb.ts @@ -72,6 +72,7 @@ const ortRootDir = $.relativeDir("../target/ort"); const sourceArchivePath = ortRootDir.join(`onnxruntime-${ortSourceTag}.tar.gz`); const sourceDir = ortRootDir.join(`onnxruntime-src-${ortSourceTag}`); const sourceCompleteFile = ortRootDir.join(`.source-${ortSourceTag}.complete`); +const fetchcontentCacheDir = ortRootDir.join("fetchcontent-cache", ortSourceTag); const distDir = ortRootDir.join( "dist", ortSourceTag, @@ -87,6 +88,7 @@ const libDirFile = ortRootDir.join( ); await ortRootDir.ensureDir(); +await fetchcontentCacheDir.ensureDir(); if (!((await distCompleteFile.exists()) && (await libDirFile.exists()))) { const needsSourceExtract = !(await sourceDir.exists()); @@ -107,7 +109,7 @@ if (!((await distCompleteFile.exists()) && (await libDirFile.exists()))) { await sourceCompleteFile.writeText("ok\n"); } - await $`bash ./build.sh --update --build --config ${ortBuildConfig} --parallel --compile_no_warning_as_error --skip_submodule_sync --build_shared_lib --android --android_abi=${abi} --android_api=${androidApiLevel} --android_ndk_path=${androidNdkRoot}` + await $`bash ./build.sh --update --build --config ${ortBuildConfig} --parallel --compile_no_warning_as_error --skip_submodule_sync --build_shared_lib --android --android_abi=${abi} --android_api=${androidApiLevel} --android_ndk_path=${androidNdkRoot} --cmake_extra_defines FETCHCONTENT_BASE_DIR=${fetchcontentCacheDir}` .cwd( sourceDir, ); @@ -137,7 +139,8 @@ if (!((await distCompleteFile.exists()) && (await libDirFile.exists()))) { await distCompleteFile.writeText("ok\n"); } -if (await sourceDir.exists()) { +if (await sourceDir.exists() && $.env.DAYBOOK_CLEAN_ORT_ANDROID_BUILD !== "0") { + // Default cleanup keeps disk use low. Set DAYBOOK_CLEAN_ORT_ANDROID_BUILD=0 to keep intermediates. await cleanupOrtBuildArtifacts(sourceDir); } From 7d8d2d3fceeb4ffd553ebf29c42221b8891cb65d Mon Sep 17 00:00:00 2001 From: dman-os <61868957+dman-os@users.noreply.github.com> Date: Mon, 13 Apr 2026 07:43:28 +0300 Subject: [PATCH 05/16] wip: wip --- .../daybook/tables/CenterNavBarContent.kt | 27 +- .../daybook/tables/FloatingBottomBar.kt | 285 ++++++++++++++++++ .../org/example/daybook/tables/compact.kt | 217 ++++++------- 3 files changed, 424 insertions(+), 105 deletions(-) create mode 100644 src/daybook_compose/composeApp/src/commonMain/kotlin/org/example/daybook/tables/FloatingBottomBar.kt diff --git a/src/daybook_compose/composeApp/src/commonMain/kotlin/org/example/daybook/tables/CenterNavBarContent.kt b/src/daybook_compose/composeApp/src/commonMain/kotlin/org/example/daybook/tables/CenterNavBarContent.kt index 64560b8c..d97dc136 100644 --- a/src/daybook_compose/composeApp/src/commonMain/kotlin/org/example/daybook/tables/CenterNavBarContent.kt +++ b/src/daybook_compose/composeApp/src/commonMain/kotlin/org/example/daybook/tables/CenterNavBarContent.kt @@ -35,6 +35,7 @@ import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.lifecycle.viewmodel.compose.viewModel import androidx.navigation.NavHostController +import androidx.navigation.compose.currentBackStackEntryAsState import kotlinx.coroutines.launch import org.example.daybook.AppScreens import org.example.daybook.ChromeState @@ -62,6 +63,8 @@ fun RowScope.CenterNavBarContent( modifier: Modifier = Modifier ) { val scope = rememberCoroutineScope() + val navBackStackEntry by navController.currentBackStackEntryAsState() + val currentRoute = navBackStackEntry?.destination?.route // Get chrome state from manager val chromeStateManager = LocalChromeStateManager.current @@ -114,7 +117,10 @@ fun RowScope.CenterNavBarContent( }, icon = { button.icon() }, label = { button.label() }, - selected = hoverOver || readyState, + selected = + hoverOver || + readyState || + isFeatureRouteSelected(button.key, currentRoute), enabled = button.enabled ) } @@ -175,9 +181,26 @@ fun RowScope.CenterNavBarContent( label = { Text(feature.label, style = MaterialTheme.typography.labelSmall) }, - selected = hoverOver || ready + selected = + hoverOver || + ready || + isFeatureRouteSelected(feature.key, currentRoute) ) } } } } + +private fun isFeatureRouteSelected(featureKey: String, currentRoute: String?): Boolean { + val targetRoute = + when (featureKey) { + FeatureKeys.Home -> AppScreens.Home.name + FeatureKeys.Capture -> AppScreens.Capture.name + FeatureKeys.Drawer -> AppScreens.Drawer.name + FeatureKeys.Tables -> AppScreens.Tables.name + FeatureKeys.Progress -> AppScreens.Progress.name + FeatureKeys.Settings -> AppScreens.Settings.name + else -> null + } + return targetRoute != null && targetRoute == currentRoute +} diff --git a/src/daybook_compose/composeApp/src/commonMain/kotlin/org/example/daybook/tables/FloatingBottomBar.kt b/src/daybook_compose/composeApp/src/commonMain/kotlin/org/example/daybook/tables/FloatingBottomBar.kt new file mode 100644 index 00000000..ebff182b --- /dev/null +++ b/src/daybook_compose/composeApp/src/commonMain/kotlin/org/example/daybook/tables/FloatingBottomBar.kt @@ -0,0 +1,285 @@ +package org.example.daybook.tables + +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.tween +import androidx.compose.foundation.gestures.Orientation +import androidx.compose.foundation.gestures.draggable +import androidx.compose.foundation.gestures.rememberDraggableState +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.RowScope +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.safeContentPadding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.ChevronRight +import androidx.compose.material.icons.filled.KeyboardArrowUp +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.NavigationDrawerItem +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.drawWithContent +import androidx.compose.ui.geometry.CornerRadius +import androidx.compose.ui.geometry.Rect +import androidx.compose.ui.geometry.RoundRect +import androidx.compose.ui.graphics.Outline +import androidx.compose.ui.graphics.Path +import androidx.compose.ui.graphics.Shape +import androidx.compose.ui.graphics.drawscope.clipRect +import androidx.compose.ui.layout.boundsInWindow +import androidx.compose.ui.layout.onGloballyPositioned +import androidx.compose.ui.layout.onSizeChanged +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.unit.LayoutDirection +import androidx.compose.ui.unit.dp +import kotlinx.coroutines.launch + +private object FloatingBarDefaults { + val barHeight = 56.dp + val horizontalPadding = 16.dp + val verticalPadding = 2.dp + val menuTopRadius = 28.dp +} + +private class NippleBarShape( + private val protrusionPx: Float, + private val cornerRadiusPx: Float +) : Shape { + override fun createOutline( + size: androidx.compose.ui.geometry.Size, + layoutDirection: LayoutDirection, + density: androidx.compose.ui.unit.Density + ): Outline { + val protrusion = protrusionPx.coerceAtLeast(0f) + val centerY = size.height / 2f + val nippleRadius = (size.height * 0.16f).coerceAtLeast(1f) + val bodyLeft = protrusion + val bodyRight = size.width - protrusion + + val path = Path().apply { + addRoundRect( + RoundRect( + left = bodyLeft, + top = 0f, + right = bodyRight, + bottom = size.height, + cornerRadius = CornerRadius(cornerRadiusPx, cornerRadiusPx) + ) + ) + if (protrusion > 0f) { + addOval( + Rect( + left = 0f, + top = centerY - nippleRadius, + right = protrusion * 2f, + bottom = centerY + nippleRadius + ) + ) + addOval( + Rect( + left = size.width - protrusion * 2f, + top = centerY - nippleRadius, + right = size.width, + bottom = centerY + nippleRadius + ) + ) + } + } + return Outline.Generic(path) + } +} + +@Composable +fun FloatingBottomNavigationBar( + centerContent: @Composable RowScope.() -> Unit, + menuOpenProgress: Float, + bottomBarModifier: Modifier = Modifier +) { + val density = LocalDensity.current + val animatedOpenProgress by animateFloatAsState( + targetValue = menuOpenProgress.coerceIn(0f, 1f), + animationSpec = tween(durationMillis = 180), + label = "floating_bar_open_progress" + ) + val protrusionFraction = 1f - animatedOpenProgress + val protrusionPx = with(density) { 8.dp.toPx() } * protrusionFraction + val cornerRadiusPx = with(density) { 28.dp.toPx() } + val nippleAlpha = protrusionFraction + + Box( + modifier = + bottomBarModifier + .fillMaxWidth() + .safeContentPadding() + .padding( + horizontal = FloatingBarDefaults.horizontalPadding, + vertical = FloatingBarDefaults.verticalPadding + ) + ) { + Surface( + modifier = Modifier.fillMaxWidth().height(FloatingBarDefaults.barHeight), + color = MaterialTheme.colorScheme.surfaceContainerLow.copy(alpha = 0.94f), + shape = NippleBarShape(protrusionPx = protrusionPx, cornerRadiusPx = cornerRadiusPx), + shadowElevation = 10.dp, + tonalElevation = 0.dp + ) { + Row( + modifier = Modifier.fillMaxSize().padding(horizontal = 30.dp), + verticalAlignment = Alignment.CenterVertically + ) { + centerContent() + } + } + + Icon( + imageVector = Icons.Default.ChevronRight, + contentDescription = "Swipe right for drawer", + tint = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.78f * nippleAlpha), + modifier = Modifier.align(Alignment.CenterStart).padding(start = 7.dp).width(13.dp).height(13.dp) + ) + Icon( + imageVector = Icons.Default.KeyboardArrowUp, + contentDescription = "Swipe up for menu", + tint = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.78f * nippleAlpha), + modifier = Modifier.align(Alignment.CenterEnd).padding(end = 7.dp).width(13.dp).height(13.dp) + ) + } +} + +@Composable +fun FloatingGrowingMenuSheet( + sheetState: RevealBottomSheetState, + maxAnchor: Float, + menuItems: List, + highlightedMenuItem: String?, + enableDragToClose: Boolean, + onMenuItemLayout: (key: String, rect: Rect) -> Unit, + onDismiss: () -> Unit, + onItemActivate: suspend (FeatureItem) -> Unit, + modifier: Modifier = Modifier +) { + if (!sheetState.isVisible) return + + val scope = rememberCoroutineScope() + val density = LocalDensity.current + var sheetHeightPx by remember { mutableIntStateOf(1) } + val maxMenuHeight = remember { 560.dp } + val bottomInset = with(density) { (FloatingBarDefaults.barHeight.toPx() + 8.dp.toPx()).toDp() } + + val dragModifier = + if (enableDragToClose) { + Modifier.draggable( + state = + rememberDraggableState { dragAmount -> + val total = sheetHeightPx.coerceAtLeast(1).toFloat() + val boundedProgress = sheetState.progress.coerceIn(0f, maxAnchor) + val currentVisible = total * boundedProgress + val nextVisible = (currentVisible - dragAmount).coerceIn(0f, total) + val nextProgress = (nextVisible / total).coerceIn(0f, maxAnchor) + sheetState.setProgressImmediate(nextProgress) + }, + orientation = Orientation.Vertical, + onDragStopped = { velocityY -> + sheetState.settle(velocityY) + if (sheetState.progress <= 0f) { + onDismiss() + } + } + ) + } else { + Modifier + } + + Surface( + modifier = + modifier + .fillMaxSize() + .padding(horizontal = FloatingBarDefaults.horizontalPadding) + .padding(bottom = FloatingBarDefaults.verticalPadding) + .onSizeChanged { sheetHeightPx = it.height.coerceAtLeast(1) } + .drawWithReveal( + sheetHeightPx = sheetHeightPx, + progress = sheetState.progress.coerceIn(0f, maxAnchor) + ) + .then(dragModifier), + color = MaterialTheme.colorScheme.surfaceContainerLow.copy(alpha = 0.96f), + shape = RoundedCornerShape( + topStart = FloatingBarDefaults.menuTopRadius, + topEnd = FloatingBarDefaults.menuTopRadius, + bottomStart = 28.dp, + bottomEnd = 28.dp + ), + shadowElevation = 12.dp, + tonalElevation = 0.dp + ) { + Column( + modifier = Modifier.fillMaxWidth().heightIn(max = maxMenuHeight).padding(horizontal = 16.dp), + verticalArrangement = Arrangement.Bottom + ) { + Spacer(Modifier.weight(1f, fill = true)) + Column( + modifier = + Modifier + .fillMaxWidth() + .verticalScroll(rememberScrollState()), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + menuItems.forEach { item -> + NavigationDrawerItem( + selected = item.key == highlightedMenuItem, + onClick = { + scope.launch { + if (item.enabled) { + onItemActivate(item) + } + } + }, + icon = { item.icon() }, + label = { item.labelContent?.invoke() ?: Text(item.label) }, + modifier = + Modifier + .fillMaxWidth() + .onGloballyPositioned { + onMenuItemLayout(item.key, it.boundsInWindow()) + } + ) + } + Spacer(Modifier.height(bottomInset)) + } + } + } +} + +private fun Modifier.drawWithReveal(sheetHeightPx: Int, progress: Float): Modifier = + this.then( + Modifier + .heightIn(max = 560.dp) + .fillMaxWidth() + .drawWithContent { + val total = sheetHeightPx.coerceAtLeast(1).toFloat() + val visible = (total * progress).coerceIn(0f, total) + val revealTop = size.height - visible + clipRect(top = revealTop.coerceAtLeast(0f)) { + this@drawWithContent.drawContent() + } + } + ) diff --git a/src/daybook_compose/composeApp/src/commonMain/kotlin/org/example/daybook/tables/compact.kt b/src/daybook_compose/composeApp/src/commonMain/kotlin/org/example/daybook/tables/compact.kt index 7257018d..a6d699a6 100644 --- a/src/daybook_compose/composeApp/src/commonMain/kotlin/org/example/daybook/tables/compact.kt +++ b/src/daybook_compose/composeApp/src/commonMain/kotlin/org/example/daybook/tables/compact.kt @@ -185,8 +185,10 @@ fun CompactLayout( ) { var showFeaturesMenu by remember { mutableStateOf(false) } val scope = rememberCoroutineScope() - val revealSheetState = rememberRevealBottomSheetState(initiallyVisible = false) + val tabsSheetState = rememberRevealBottomSheetState(initiallyVisible = false) + val menuSheetState = rememberRevealBottomSheetState(initiallyVisible = false) var sheetContent by remember { mutableStateOf(SheetContent.MENU) } + var menuCloseDragEnabled by remember { mutableStateOf(false) } val leftDrawerState = rememberDrawerState(initialValue = DrawerValue.Closed) // Config ViewModel @@ -250,6 +252,11 @@ fun CompactLayout( baseMenuFeatures } } + val nonProminentButtons = chromeState.additionalFeatureButtons.filter { !it.prominent } + val allMenuItems = + remember(menuFeatures, nonProminentButtons) { + menuFeatures.withAdditionalFeatureButtons(nonProminentButtons) + } // Create controllers and ready-state trackers for each navBar feature (used in center rollout) val navBarFeatureKeys = navBarFeatures.map { it.key } @@ -291,10 +298,14 @@ fun CompactLayout( horizontalDragDistance = 0f }, onDrag = { change, dragAmount -> + if (menuSheetState.isVisible && menuCloseDragEnabled) { + return@detectDragGestures + } horizontalDragDistance += dragAmount.x if (!menuSheetOpenedByDrag && dragAmount.y < 0f && kotlin.math.abs(dragAmount.y) > kotlin.math.abs(dragAmount.x)) { sheetContent = SheetContent.MENU - revealSheetState.openToContent(SheetContent.MENU, scope) + menuCloseDragEnabled = false + menuSheetState.openToContent(SheetContent.MENU, scope) menuSheetOpenedByDrag = true } if (!menuSheetOpenedByDrag) { @@ -351,7 +362,7 @@ fun CompactLayout( // If released over a menu item, activate it and close if (highlightedMenuItem != null && lastDragWindowPos != null) { val menuItemKey = highlightedMenuItem - val feature = menuFeatures.find { it.key == menuItemKey } + val feature = allMenuItems.find { it.key == menuItemKey } if (feature != null) { feature.onActivate() shouldClose = true @@ -395,9 +406,11 @@ fun CompactLayout( // Close sheet if item was activated, otherwise settle to nearest anchor if (shouldClose) { - revealSheetState.hide() + menuCloseDragEnabled = false + menuSheetState.hide() } else { - revealSheetState.settle(0f) + menuCloseDragEnabled = true + menuSheetState.settle(0f) } } }, @@ -410,7 +423,8 @@ fun CompactLayout( lastDragWindowPos = null isDragging = false menuSheetOpenedByDrag = false - revealSheetState.hide() + menuCloseDragEnabled = false + menuSheetState.hide() showFeaturesMenu = false } } @@ -422,7 +436,9 @@ fun CompactLayout( LaunchedEffect(isDocEditorFullscreen) { if (isDocEditorFullscreen) { - revealSheetState.hide() + tabsSheetState.hide() + menuSheetState.hide() + menuCloseDragEnabled = false leftDrawerState.close() showFeaturesMenu = false } @@ -441,15 +457,25 @@ fun CompactLayout( // Ensure sheet snaps to correct anchor when content changes while sheet is open LaunchedEffect(sheetContent) { - if (revealSheetState.isVisible) { - revealSheetState.ensureValidAnchor(sheetContent, scope) + if (sheetContent == SheetContent.TABS && tabsSheetState.isVisible) { + tabsSheetState.ensureValidAnchor(sheetContent, scope) + } + } + LaunchedEffect(menuSheetState) { + menuSheetState.setAnchors(SheetConfig.getAnchors(SheetContent.MENU)) + } + LaunchedEffect(menuSheetState.isVisible) { + if (!menuSheetState.isVisible) { + menuCloseDragEnabled = false + highlightedMenuItem = null + lastDragWindowPos = null } } val centerNavBarContent: @Composable RowScope.() -> Unit = { CenterNavBarContent( navController = navController, - isMenuOpen = revealSheetState.isVisible && sheetContent == SheetContent.MENU, + isMenuOpen = menuSheetState.isVisible, showFeaturesMenu = showFeaturesMenu, featureReadyStates = featureReadyStates, features = navBarFeatures, @@ -463,8 +489,8 @@ fun CompactLayout( scope.launch { feature.onActivate() // Close the sheet if it's open and showing the menu - if (revealSheetState.isVisible && sheetContent == SheetContent.MENU) { - revealSheetState.hide() + if (menuSheetState.isVisible) { + menuSheetState.hide() } } } @@ -509,32 +535,37 @@ fun CompactLayout( modifier = modifier, bottomBar = { if (!isDocEditorFullscreen) { - DaybookBottomNavigationBar( + val menuOpenProgress = + if (menuSheetState.isVisible) { + (menuSheetState.progress / SheetConfig.MENU_MAX_ANCHOR).coerceIn(0f, 1f) + } else { + 0f + } + FloatingBottomNavigationBar( centerContent = { centerNavBarContent() }, - // showLeftDrawerHint = leftDrawerState.currentValue == DrawerValue.Closed && !isLeftDrawerDragging, - showLeftDrawerHint = true, + menuOpenProgress = menuOpenProgress, bottomBarModifier = menuGestureModifier, ) } }, snackbarHost = { SnackbarHost(snackbarHostState) } ) { scaffoldPadding -> - Box( - modifier = - Modifier - .fillMaxSize() - .padding(scaffoldPadding) - ) { - RevealBottomSheetScaffold( - sheetState = revealSheetState, - // For TABS sheet: hidden and expanded anchors. For MENU sheet: hidden, 2/3, and expanded anchors - sheetAnchors = SheetConfig.getAnchors(sheetContent), + Box(modifier = Modifier.fillMaxSize()) { + Box( + modifier = + Modifier + .fillMaxSize() + .padding(scaffoldPadding) + ) { + RevealBottomSheetScaffold( + sheetState = tabsSheetState, + sheetAnchors = SheetConfig.getAnchors(SheetContent.TABS), sheetDragHandle = null, - sheetHeader = { headerModifier: Modifier -> - when (sheetContent) { - SheetContent.TABS -> { + sheetHeader = + if (sheetContent == SheetContent.TABS) { + { headerModifier: Modifier -> // Place table title / header when in TABS sheet val tablesState = vm.tablesState.collectAsState().value val selectedTableId = vm.selectedTableId.collectAsState().value @@ -609,55 +640,10 @@ fun CompactLayout( } } } - } - - SheetContent.MENU -> { - // Header for menu sheet - Surface( - modifier = headerModifier.fillMaxWidth(), - color = Color.Transparent - ) { - Column( - modifier = - Modifier - .fillMaxWidth() - .background(MaterialTheme.colorScheme.surfaceContainerLow) - ) { - // handle drawn in header - Box( - modifier = - Modifier - .fillMaxWidth() - .padding(top = 8.dp, bottom = 4.dp), - contentAlignment = Alignment.Center - ) { - Box( - modifier = - Modifier - .height(4.dp) - .width(36.dp) - .background( - MaterialTheme.colorScheme.onSurface.copy( - alpha = 0.12f - ), - shape = RoundedCornerShape(2.dp) - ) - ) - } - Row( - modifier = Modifier.padding(16.dp), - verticalAlignment = Alignment.CenterVertically - ) { - Text( - text = "Menu", - style = MaterialTheme.typography.titleMedium - ) - } - } - } } - } - }, + } else { + null + }, topBar = { // Get chrome state manager and observe the current state (from the current screen) val chromeStateManager = LocalChromeStateManager.current @@ -686,23 +672,24 @@ fun CompactLayout( ChromeStateTopAppBar(mergedChromeState) }, sheetContent = { - SheetContentHost( - sheetContent = sheetContent, + if (sheetContent == SheetContent.TABS) { + SheetContentHost( + sheetContent = SheetContent.TABS, onTabSelected = { // When the user selects a tab from the sheet, route it via the vm vm.selectTab(it.id) - revealSheetState.hide() + tabsSheetState.hide() }, onTableSelected = { table -> vm.selectTable(table.id) }, onDismiss = { - revealSheetState.hide() + tabsSheetState.hide() }, onFeatureActivate = { showFeaturesMenu = false scope.launch { - revealSheetState.hide() + tabsSheetState.hide() } }, onTabLayout = { tabId, rect -> @@ -722,37 +709,61 @@ fun CompactLayout( addTableController = addTableController, highlightedTab = highlightedTab, highlightedTable = highlightedTable, - features = menuFeatures, - onMenuItemLayout = { key, rect -> - menuItemLayouts = menuItemLayouts + (key to rect) - }, - highlightedMenuItem = highlightedMenuItem, + features = emptyList(), + onMenuItemLayout = { _, _ -> }, + highlightedMenuItem = null, tableViewMode = tableViewMode ) + } }, sheetPeekHeight = 0.dp, modifier = Modifier.matchParentSize() - ) { contentPadding -> - Box( - modifier = - Modifier - .fillMaxSize() - .padding(contentPadding) - ) { - Row(modifier = Modifier.fillMaxSize()) { - Box(modifier = Modifier.weight(1f, fill = true)) { - Routes( - modifier = Modifier.fillMaxSize(), - navController = navController, - extraAction = extraAction, - contentType = contentType - ) - } + ) { contentPadding -> + Box( + modifier = + Modifier + .fillMaxSize() + .padding(contentPadding) + ) { + Row(modifier = Modifier.fillMaxSize()) { + Box(modifier = Modifier.weight(1f, fill = true)) { + Routes( + modifier = Modifier.fillMaxSize(), + navController = navController, + extraAction = extraAction, + contentType = contentType + ) + } - // Sidebar not shown in compact view + // Sidebar not shown in compact view + } } } } + FloatingGrowingMenuSheet( + sheetState = menuSheetState, + maxAnchor = SheetConfig.MENU_MAX_ANCHOR, + menuItems = allMenuItems, + highlightedMenuItem = highlightedMenuItem, + enableDragToClose = menuCloseDragEnabled, + onMenuItemLayout = { key, rect -> + menuItemLayouts = menuItemLayouts + (key to rect) + }, + onDismiss = { + scope.launch { + menuSheetState.hide() + } + }, + onItemActivate = { item -> + if (item.enabled) { + item.onActivate() + } + menuCloseDragEnabled = false + showFeaturesMenu = false + menuSheetState.hide() + }, + modifier = Modifier.fillMaxSize() + ) } } From 03ee6fac0ab50e400e36f302500bfffac739eaf6 Mon Sep 17 00:00:00 2001 From: dman-os <61868957+dman-os@users.noreply.github.com> Date: Mon, 13 Apr 2026 08:15:40 +0300 Subject: [PATCH 06/16] wip: good state --- .../daybook/tables/FloatingBottomBar.kt | 156 ++++++++++-------- .../tables/RevealBottomSheetScaffold.kt | 7 +- .../org/example/daybook/tables/compact.kt | 4 + 3 files changed, 95 insertions(+), 72 deletions(-) diff --git a/src/daybook_compose/composeApp/src/commonMain/kotlin/org/example/daybook/tables/FloatingBottomBar.kt b/src/daybook_compose/composeApp/src/commonMain/kotlin/org/example/daybook/tables/FloatingBottomBar.kt index ebff182b..a532cc30 100644 --- a/src/daybook_compose/composeApp/src/commonMain/kotlin/org/example/daybook/tables/FloatingBottomBar.kt +++ b/src/daybook_compose/composeApp/src/commonMain/kotlin/org/example/daybook/tables/FloatingBottomBar.kt @@ -1,10 +1,12 @@ package org.example.daybook.tables +import androidx.compose.animation.core.Animatable import androidx.compose.animation.core.animateFloatAsState import androidx.compose.animation.core.tween import androidx.compose.foundation.gestures.Orientation import androidx.compose.foundation.gestures.draggable import androidx.compose.foundation.gestures.rememberDraggableState +import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -14,11 +16,11 @@ import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.safeContentPadding import androidx.compose.foundation.layout.width import androidx.compose.foundation.rememberScrollState +import androidx.compose.ui.draw.clip import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons @@ -31,32 +33,29 @@ import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.drawWithContent import androidx.compose.ui.geometry.CornerRadius import androidx.compose.ui.geometry.Rect import androidx.compose.ui.geometry.RoundRect import androidx.compose.ui.graphics.Outline import androidx.compose.ui.graphics.Path import androidx.compose.ui.graphics.Shape -import androidx.compose.ui.graphics.drawscope.clipRect import androidx.compose.ui.layout.boundsInWindow import androidx.compose.ui.layout.onGloballyPositioned -import androidx.compose.ui.layout.onSizeChanged import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.unit.LayoutDirection import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.times import kotlinx.coroutines.launch private object FloatingBarDefaults { val barHeight = 56.dp val horizontalPadding = 16.dp - val verticalPadding = 2.dp + val verticalPadding = 8.dp val menuTopRadius = 28.dp } @@ -143,7 +142,7 @@ fun FloatingBottomNavigationBar( tonalElevation = 0.dp ) { Row( - modifier = Modifier.fillMaxSize().padding(horizontal = 30.dp), + modifier = Modifier.fillMaxSize().padding(horizontal = 30.dp, vertical = 5.dp), verticalAlignment = Alignment.CenterVertically ) { centerContent() @@ -181,61 +180,90 @@ fun FloatingGrowingMenuSheet( val scope = rememberCoroutineScope() val density = LocalDensity.current - var sheetHeightPx by remember { mutableIntStateOf(1) } val maxMenuHeight = remember { 560.dp } + val maxMenuHeightPx = with(density) { maxMenuHeight.toPx().coerceAtLeast(1f) } val bottomInset = with(density) { (FloatingBarDefaults.barHeight.toPx() + 8.dp.toPx()).toDp() } + val flingCloseThreshold = 220f - val dragModifier = - if (enableDragToClose) { - Modifier.draggable( - state = - rememberDraggableState { dragAmount -> - val total = sheetHeightPx.coerceAtLeast(1).toFloat() - val boundedProgress = sheetState.progress.coerceIn(0f, maxAnchor) - val currentVisible = total * boundedProgress - val nextVisible = (currentVisible - dragAmount).coerceIn(0f, total) - val nextProgress = (nextVisible / total).coerceIn(0f, maxAnchor) - sheetState.setProgressImmediate(nextProgress) - }, - orientation = Orientation.Vertical, - onDragStopped = { velocityY -> - sheetState.settle(velocityY) - if (sheetState.progress <= 0f) { - onDismiss() + Box(modifier = modifier.fillMaxSize().padding(horizontal = FloatingBarDefaults.horizontalPadding)) { + val openFraction = (sheetState.progress / maxAnchor).coerceIn(0f, 1f) + val targetHeight = (maxMenuHeight * openFraction) + val dragModifier = + if (enableDragToClose) { + Modifier.draggable( + state = + rememberDraggableState { dragAmount -> + val total = maxMenuHeightPx + val boundedProgress = sheetState.progress.coerceIn(0f, maxAnchor) + val currentVisible = total * (boundedProgress / maxAnchor).coerceIn(0f, 1f) + val nextVisible = (currentVisible - dragAmount).coerceIn(0f, total) + val nextProgress = ((nextVisible / total) * maxAnchor).coerceIn(0f, maxAnchor) + sheetState.setProgressImmediate(nextProgress) + }, + orientation = Orientation.Vertical, + onDragStopped = { velocityY -> + if (velocityY > flingCloseThreshold) { + scope.launch { + val anim = Animatable(sheetState.progress.coerceIn(0f, maxAnchor)) + anim.animateTo(0f, animationSpec = tween(durationMillis = 200)) { + sheetState.setProgressImmediate(value) + } + sheetState.hideInstant() + onDismiss() + } + } else { + sheetState.settle(velocityY) { settledProgress -> + if (settledProgress <= 0f) { + onDismiss() + } + } + } } - } - ) - } else { - Modifier - } - - Surface( - modifier = - modifier - .fillMaxSize() - .padding(horizontal = FloatingBarDefaults.horizontalPadding) - .padding(bottom = FloatingBarDefaults.verticalPadding) - .onSizeChanged { sheetHeightPx = it.height.coerceAtLeast(1) } - .drawWithReveal( - sheetHeightPx = sheetHeightPx, - progress = sheetState.progress.coerceIn(0f, maxAnchor) ) - .then(dragModifier), - color = MaterialTheme.colorScheme.surfaceContainerLow.copy(alpha = 0.96f), - shape = RoundedCornerShape( - topStart = FloatingBarDefaults.menuTopRadius, - topEnd = FloatingBarDefaults.menuTopRadius, - bottomStart = 28.dp, - bottomEnd = 28.dp - ), - shadowElevation = 12.dp, - tonalElevation = 0.dp - ) { - Column( - modifier = Modifier.fillMaxWidth().heightIn(max = maxMenuHeight).padding(horizontal = 16.dp), - verticalArrangement = Arrangement.Bottom + } else { + Modifier + } + val surfaceHeight = targetHeight.coerceAtLeast(1.dp) + + Surface( + modifier = + Modifier + .align(Alignment.BottomCenter) + .fillMaxWidth() + .padding(bottom = FloatingBarDefaults.verticalPadding) + .height(surfaceHeight) + .then(dragModifier), + color = MaterialTheme.colorScheme.surfaceContainerLow.copy(alpha = 0.96f), + shape = RoundedCornerShape( + topStart = FloatingBarDefaults.menuTopRadius, + topEnd = FloatingBarDefaults.menuTopRadius, + bottomStart = 28.dp, + bottomEnd = 28.dp + ), + shadowElevation = 12.dp, + tonalElevation = 0.dp ) { - Spacer(Modifier.weight(1f, fill = true)) + Column( + modifier = Modifier.fillMaxSize().padding(horizontal = 16.dp), + verticalArrangement = Arrangement.Bottom + ) { + Box( + modifier = + Modifier + .fillMaxWidth() + .padding(top = 8.dp, bottom = 6.dp), + contentAlignment = Alignment.Center + ) { + Box( + modifier = + Modifier + .height(4.dp) + .width(36.dp) + .clip(RoundedCornerShape(2.dp)) + .background(MaterialTheme.colorScheme.onSurface.copy(alpha = 0.18f)) + ) + } + Spacer(Modifier.weight(1f, fill = true)) Column( modifier = Modifier @@ -265,21 +293,7 @@ fun FloatingGrowingMenuSheet( } Spacer(Modifier.height(bottomInset)) } + } } } } - -private fun Modifier.drawWithReveal(sheetHeightPx: Int, progress: Float): Modifier = - this.then( - Modifier - .heightIn(max = 560.dp) - .fillMaxWidth() - .drawWithContent { - val total = sheetHeightPx.coerceAtLeast(1).toFloat() - val visible = (total * progress).coerceIn(0f, total) - val revealTop = size.height - visible - clipRect(top = revealTop.coerceAtLeast(0f)) { - this@drawWithContent.drawContent() - } - } - ) diff --git a/src/daybook_compose/composeApp/src/commonMain/kotlin/org/example/daybook/tables/RevealBottomSheetScaffold.kt b/src/daybook_compose/composeApp/src/commonMain/kotlin/org/example/daybook/tables/RevealBottomSheetScaffold.kt index 839c2e69..bdc091e2 100644 --- a/src/daybook_compose/composeApp/src/commonMain/kotlin/org/example/daybook/tables/RevealBottomSheetScaffold.kt +++ b/src/daybook_compose/composeApp/src/commonMain/kotlin/org/example/daybook/tables/RevealBottomSheetScaffold.kt @@ -132,7 +132,11 @@ class RevealBottomSheetState( } } - fun settle(velocity: Float, animationSpec: AnimationSpec = spring()) { + fun settle( + velocity: Float, + animationSpec: AnimationSpec = spring(), + onSettled: ((Float) -> Unit)? = null + ) { scope.launch { val current = anim.value val anchors = this@RevealBottomSheetState.anchors @@ -158,6 +162,7 @@ class RevealBottomSheetState( anim.animateTo(target.coerceIn(0f, 1f), animationSpec = animationSpec) setProgressImmediate(anim.value) isVisible = anim.value > 0f + onSettled?.invoke(anim.value) } } } diff --git a/src/daybook_compose/composeApp/src/commonMain/kotlin/org/example/daybook/tables/compact.kt b/src/daybook_compose/composeApp/src/commonMain/kotlin/org/example/daybook/tables/compact.kt index a6d699a6..b147c1e1 100644 --- a/src/daybook_compose/composeApp/src/commonMain/kotlin/org/example/daybook/tables/compact.kt +++ b/src/daybook_compose/composeApp/src/commonMain/kotlin/org/example/daybook/tables/compact.kt @@ -751,6 +751,10 @@ fun CompactLayout( }, onDismiss = { scope.launch { + menuCloseDragEnabled = false + highlightedMenuItem = null + lastDragWindowPos = null + showFeaturesMenu = false menuSheetState.hide() } }, From b28b6fbdf3ed077935668c657ba0472c38e69d69 Mon Sep 17 00:00:00 2001 From: dman-os <61868957+dman-os@users.noreply.github.com> Date: Mon, 13 Apr 2026 09:42:35 +0300 Subject: [PATCH 07/16] wip: wip --- .../daybook/capture/CaptureNavActions.kt | 18 +++ .../daybook/capture/screens/CaptureScreen.kt | 18 +++ .../daybook/tables/CenterNavBarContent.kt | 150 +++++++++++++----- .../org/example/daybook/tables/FeatureItem.kt | 7 +- .../org/example/daybook/tables/Features.kt | 80 +++++++--- .../daybook/tables/FloatingBottomBar.kt | 34 +++- .../org/example/daybook/tables/compact.kt | 69 ++++++-- 7 files changed, 294 insertions(+), 82 deletions(-) create mode 100644 src/daybook_compose/composeApp/src/commonMain/kotlin/org/example/daybook/capture/CaptureNavActions.kt diff --git a/src/daybook_compose/composeApp/src/commonMain/kotlin/org/example/daybook/capture/CaptureNavActions.kt b/src/daybook_compose/composeApp/src/commonMain/kotlin/org/example/daybook/capture/CaptureNavActions.kt new file mode 100644 index 00000000..15b71116 --- /dev/null +++ b/src/daybook_compose/composeApp/src/commonMain/kotlin/org/example/daybook/capture/CaptureNavActions.kt @@ -0,0 +1,18 @@ +package org.example.daybook.capture + +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.asSharedFlow + +/** + * Cross-screen nav actions for Capture re-tap behavior from the bottom bar. + */ +object CaptureNavActions { + private val _modeCycleRequests = MutableSharedFlow(extraBufferCapacity = 1) + val modeCycleRequests: SharedFlow = _modeCycleRequests.asSharedFlow() + + fun requestModeCycle() { + _modeCycleRequests.tryEmit(Unit) + } +} + diff --git a/src/daybook_compose/composeApp/src/commonMain/kotlin/org/example/daybook/capture/screens/CaptureScreen.kt b/src/daybook_compose/composeApp/src/commonMain/kotlin/org/example/daybook/capture/screens/CaptureScreen.kt index 8e14f9b9..69dbecc2 100644 --- a/src/daybook_compose/composeApp/src/commonMain/kotlin/org/example/daybook/capture/screens/CaptureScreen.kt +++ b/src/daybook_compose/composeApp/src/commonMain/kotlin/org/example/daybook/capture/screens/CaptureScreen.kt @@ -25,6 +25,7 @@ import org.example.daybook.MainFeatureActionButton import org.example.daybook.ProvideChromeState import org.example.daybook.TablesState import org.example.daybook.TablesViewModel +import org.example.daybook.capture.CaptureNavActions import org.example.daybook.capture.LocalCameraCaptureContext import org.example.daybook.capture.ui.DaybookCameraViewport import org.example.daybook.ui.DocEditor @@ -81,6 +82,17 @@ class CaptureScreenViewModel( persistCaptureMode(mode) } + fun cycleCaptureMode() { + val next = + when (_captureMode.value) { + CaptureMode.TEXT -> CaptureMode.CAMERA + CaptureMode.CAMERA -> CaptureMode.MIC + CaptureMode.MIC -> CaptureMode.TEXT + } + _captureMode.value = next + persistCaptureMode(next) + } + private fun persistCaptureMode(mode: CaptureMode) { viewModelScope.launch { val state = tablesVm.tablesState.value @@ -245,6 +257,12 @@ fun CaptureScreen(modifier: Modifier = Modifier, initialDocId: String? = null) { val captureMode by vm.captureMode.collectAsState() + LaunchedEffect(vm) { + CaptureNavActions.modeCycleRequests.collect { + vm.cycleCaptureMode() + } + } + val captureContext = LocalCameraCaptureContext.current val canCapture = if (captureContext != null && captureMode == CaptureMode.CAMERA) { diff --git a/src/daybook_compose/composeApp/src/commonMain/kotlin/org/example/daybook/tables/CenterNavBarContent.kt b/src/daybook_compose/composeApp/src/commonMain/kotlin/org/example/daybook/tables/CenterNavBarContent.kt index d97dc136..6989f526 100644 --- a/src/daybook_compose/composeApp/src/commonMain/kotlin/org/example/daybook/tables/CenterNavBarContent.kt +++ b/src/daybook_compose/composeApp/src/commonMain/kotlin/org/example/daybook/tables/CenterNavBarContent.kt @@ -11,16 +11,21 @@ import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut import androidx.compose.animation.slideInHorizontally import androidx.compose.animation.slideOutHorizontally +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.RowScope import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.Button import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.NavigationBarItem import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState @@ -28,22 +33,17 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip import androidx.compose.ui.geometry.Rect import androidx.compose.ui.layout.boundsInWindow import androidx.compose.ui.layout.onGloballyPositioned -import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp -import androidx.lifecycle.viewmodel.compose.viewModel import androidx.navigation.NavHostController import androidx.navigation.compose.currentBackStackEntryAsState import kotlinx.coroutines.launch import org.example.daybook.AppScreens -import org.example.daybook.ChromeState import org.example.daybook.LocalChromeStateManager -import org.example.daybook.LocalContainer import org.example.daybook.MainFeatureActionButton -import org.example.daybook.TablesState -import org.example.daybook.TablesViewModel /** * Abstraction for center navigation bar content that adapts based on navigation state @@ -65,6 +65,9 @@ fun RowScope.CenterNavBarContent( val scope = rememberCoroutineScope() val navBackStackEntry by navController.currentBackStackEntryAsState() val currentRoute = navBackStackEntry?.destination?.route + val armedIndicatorColor = MaterialTheme.colorScheme.primary.copy(alpha = 0.95f) + val hoverFill = MaterialTheme.colorScheme.primary.copy(alpha = 0.12f) + val selectedFill = MaterialTheme.colorScheme.primary.copy(alpha = 0.24f) // Get chrome state from manager val chromeStateManager = LocalChromeStateManager.current @@ -98,30 +101,31 @@ fun RowScope.CenterNavBarContent( featureButtonLayouts[prominentButtonKey]?.contains(pw) } ?: false - NavigationBarItem( - onClick = { - if (button.enabled) { - scope.launch { - button.onClick() - } - } - }, + val selected = isFeatureRouteSelected(button.key, currentRoute) + CustomBottomBarItem( modifier = Modifier - .weight(1f) + .padding(vertical = 3.dp) .onGloballyPositioned { layoutCoordinates -> onFeatureButtonLayout( button.key, layoutCoordinates.boundsInWindow() ) }, - icon = { button.icon() }, - label = { button.label() }, - selected = - hoverOver || - readyState || - isFeatureRouteSelected(button.key, currentRoute), - enabled = button.enabled + selected = selected, + hover = hoverOver, + armed = hoverOver && readyState, + enabled = button.enabled, + hoverFill = hoverFill, + selectedFill = selectedFill, + armedIndicatorColor = armedIndicatorColor, + icon = button.icon, + label = button.label, + onClick = { + if (button.enabled) { + scope.launch { button.onClick() } + } + } ) } } @@ -160,37 +164,103 @@ fun RowScope.CenterNavBarContent( ?: false val ready = featureReadyStates.getOrNull(idx)?.value ?: false - NavigationBarItem( - onClick = { - scope.launch { - onFeatureActivate(feature) - } - }, + val selected = isFeatureRouteSelected(feature.key, currentRoute) + CustomBottomBarItem( modifier = Modifier - .weight(1f) + .padding(vertical = 3.dp) .onGloballyPositioned { layoutCoordinates -> onFeatureButtonLayout( feature.key, layoutCoordinates.boundsInWindow() ) }, - icon = { - feature.icon() - }, - label = { - Text(feature.label, style = MaterialTheme.typography.labelSmall) - }, - selected = - hoverOver || - ready || - isFeatureRouteSelected(feature.key, currentRoute) + selected = selected, + hover = hoverOver, + armed = hoverOver && ready, + enabled = feature.enabled, + hoverFill = hoverFill, + selectedFill = selectedFill, + armedIndicatorColor = armedIndicatorColor, + icon = if (selected) (feature.selectedIcon ?: feature.icon) else feature.icon, + label = { Text(feature.label, style = MaterialTheme.typography.labelSmall) }, + onClick = { + scope.launch { + if (selected) { + (feature.onReselect ?: { onFeatureActivate(feature) }).invoke() + } else { + onFeatureActivate(feature) + } + } + } ) } } } } +@Composable +private fun CustomBottomBarItem( + selected: Boolean, + hover: Boolean, + armed: Boolean, + enabled: Boolean, + hoverFill: androidx.compose.ui.graphics.Color, + selectedFill: androidx.compose.ui.graphics.Color, + armedIndicatorColor: androidx.compose.ui.graphics.Color, + icon: @Composable () -> Unit, + label: @Composable () -> Unit, + onClick: () -> Unit, + modifier: Modifier = Modifier +) { + val itemShape = RoundedCornerShape(20.dp) + val background = + when { + selected -> selectedFill + hover -> hoverFill + else -> androidx.compose.ui.graphics.Color.Transparent + } + + Box( + modifier = + modifier + .padding(vertical = 2.dp), + contentAlignment = Alignment.Center + ) { + Column( + modifier = Modifier.padding(vertical = 2.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(4.dp) + ) { + Box( + modifier = + Modifier + .width(104.dp) + .clip(itemShape) + .background(background) + .then( + if (armed) { + Modifier.border(width = 1.5.dp, color = armedIndicatorColor, shape = itemShape) + } else { + Modifier + } + ) + .clickable(enabled = enabled, onClick = onClick) + .padding(horizontal = 12.dp, vertical = 6.dp), + contentAlignment = Alignment.Center + ) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(4.dp) + ) { + Box { icon() } + Box { label() } + } + } + } + } +} + private fun isFeatureRouteSelected(featureKey: String, currentRoute: String?): Boolean { val targetRoute = when (featureKey) { diff --git a/src/daybook_compose/composeApp/src/commonMain/kotlin/org/example/daybook/tables/FeatureItem.kt b/src/daybook_compose/composeApp/src/commonMain/kotlin/org/example/daybook/tables/FeatureItem.kt index f2cc2bbf..b67497bb 100644 --- a/src/daybook_compose/composeApp/src/commonMain/kotlin/org/example/daybook/tables/FeatureItem.kt +++ b/src/daybook_compose/composeApp/src/commonMain/kotlin/org/example/daybook/tables/FeatureItem.kt @@ -18,10 +18,12 @@ object FeatureKeys { data class FeatureItem( val key: String, val icon: @Composable () -> Unit, + val selectedIcon: (@Composable () -> Unit)? = null, val label: String, val labelContent: (@Composable () -> Unit)? = null, val enabled: Boolean = true, - val onActivate: suspend () -> Unit + val onActivate: suspend () -> Unit, + val onReselect: (suspend () -> Unit)? = null ) fun AdditionalFeatureButton.toFeatureItem(): FeatureItem = @@ -31,7 +33,8 @@ fun AdditionalFeatureButton.toFeatureItem(): FeatureItem = label = "", labelContent = { label() }, enabled = enabled, - onActivate = { onClick() } + onActivate = { onClick() }, + onReselect = { onClick() } ) fun List.withAdditionalFeatureButtons(buttons: List): List = diff --git a/src/daybook_compose/composeApp/src/commonMain/kotlin/org/example/daybook/tables/Features.kt b/src/daybook_compose/composeApp/src/commonMain/kotlin/org/example/daybook/tables/Features.kt index 7e39496f..3aa23837 100644 --- a/src/daybook_compose/composeApp/src/commonMain/kotlin/org/example/daybook/tables/Features.kt +++ b/src/daybook_compose/composeApp/src/commonMain/kotlin/org/example/daybook/tables/Features.kt @@ -4,7 +4,7 @@ package org.example.daybook.tables import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.LibraryBooks -import androidx.compose.material.icons.filled.CameraAlt +import androidx.compose.material.icons.filled.EditNote import androidx.compose.material.icons.filled.Home import androidx.compose.material.icons.filled.Notifications import androidx.compose.material.icons.filled.QrCode2 @@ -14,6 +14,7 @@ import androidx.compose.material3.Icon import androidx.compose.runtime.Composable import androidx.navigation.NavHostController import org.example.daybook.AppScreens +import org.example.daybook.capture.CaptureNavActions /** * All available features. This is the master list. @@ -21,21 +22,46 @@ import org.example.daybook.AppScreens @Composable fun rememberAllFeatures(navController: NavHostController): List { return listOf( - FeatureItem(FeatureKeys.Home, { Icon(Icons.Default.Home, contentDescription = "Home") }, "Home") { - navController.navigate(AppScreens.Home.name) - }, - FeatureItem(FeatureKeys.Capture, { Icon(Icons.Default.CameraAlt, contentDescription = "Capture") }, "Capture") { - navController.navigate(AppScreens.Capture.name) - }, - FeatureItem(FeatureKeys.Drawer, { Icon(Icons.AutoMirrored.Filled.LibraryBooks, contentDescription = "Drawer") }, "Drawer") { - navController.navigate(AppScreens.Drawer.name) - }, - FeatureItem(FeatureKeys.Tables, { Icon(Icons.Default.TableChart, contentDescription = "Tables") }, "Tables") { - navController.navigate(AppScreens.Tables.name) - }, - FeatureItem(FeatureKeys.Progress, { Icon(Icons.Default.Notifications, contentDescription = "Progress") }, "Progress") { - navController.navigate(AppScreens.Progress.name) - } + FeatureItem( + key = FeatureKeys.Home, + icon = { Icon(Icons.Default.Home, contentDescription = "Home") }, + selectedIcon = { Icon(Icons.Default.Home, contentDescription = "Home") }, + label = "Home", + onActivate = { navController.navigate(AppScreens.Home.name) }, + onReselect = { navController.navigate(AppScreens.Home.name) } + ), + FeatureItem( + key = FeatureKeys.Capture, + icon = { Icon(Icons.Default.EditNote, contentDescription = "Capture") }, + selectedIcon = { Icon(Icons.Default.EditNote, contentDescription = "Capture") }, + label = "Capture", + onActivate = { navController.navigate(AppScreens.Capture.name) }, + onReselect = { CaptureNavActions.requestModeCycle() } + ), + FeatureItem( + key = FeatureKeys.Drawer, + icon = { Icon(Icons.AutoMirrored.Filled.LibraryBooks, contentDescription = "Drawer") }, + selectedIcon = { Icon(Icons.AutoMirrored.Filled.LibraryBooks, contentDescription = "Drawer") }, + label = "Drawer", + onActivate = { navController.navigate(AppScreens.Drawer.name) }, + onReselect = { navController.navigate(AppScreens.Drawer.name) } + ), + FeatureItem( + key = FeatureKeys.Tables, + icon = { Icon(Icons.Default.TableChart, contentDescription = "Tables") }, + selectedIcon = { Icon(Icons.Default.TableChart, contentDescription = "Tables") }, + label = "Tables", + onActivate = { navController.navigate(AppScreens.Tables.name) }, + onReselect = { navController.navigate(AppScreens.Tables.name) } + ), + FeatureItem( + key = FeatureKeys.Progress, + icon = { Icon(Icons.Default.Notifications, contentDescription = "Progress") }, + selectedIcon = { Icon(Icons.Default.Notifications, contentDescription = "Progress") }, + label = "Progress", + onActivate = { navController.navigate(AppScreens.Progress.name) }, + onReselect = { navController.navigate(AppScreens.Progress.name) } + ) ) } @@ -90,12 +116,22 @@ fun rememberMenuFeatures( return otherFeatures + listOf( - FeatureItem(FeatureKeys.CloneShare, { Icon(Icons.Default.QrCode2, contentDescription = "Clone") }, "Clone") { - onShowCloneShare() - }, - FeatureItem(FeatureKeys.Settings, { Icon(Icons.Default.Settings, contentDescription = "Settings") }, "Settings") { - navController.navigate(AppScreens.Settings.name) - } + FeatureItem( + key = FeatureKeys.CloneShare, + icon = { Icon(Icons.Default.QrCode2, contentDescription = "Clone") }, + selectedIcon = { Icon(Icons.Default.QrCode2, contentDescription = "Clone") }, + label = "Clone", + onActivate = { onShowCloneShare() }, + onReselect = { onShowCloneShare() } + ), + FeatureItem( + key = FeatureKeys.Settings, + icon = { Icon(Icons.Default.Settings, contentDescription = "Settings") }, + selectedIcon = { Icon(Icons.Default.Settings, contentDescription = "Settings") }, + label = "Settings", + onActivate = { navController.navigate(AppScreens.Settings.name) }, + onReselect = { navController.navigate(AppScreens.Settings.name) } + ) ) } diff --git a/src/daybook_compose/composeApp/src/commonMain/kotlin/org/example/daybook/tables/FloatingBottomBar.kt b/src/daybook_compose/composeApp/src/commonMain/kotlin/org/example/daybook/tables/FloatingBottomBar.kt index a532cc30..fccdfd6a 100644 --- a/src/daybook_compose/composeApp/src/commonMain/kotlin/org/example/daybook/tables/FloatingBottomBar.kt +++ b/src/daybook_compose/composeApp/src/commonMain/kotlin/org/example/daybook/tables/FloatingBottomBar.kt @@ -7,6 +7,7 @@ import androidx.compose.foundation.gestures.Orientation import androidx.compose.foundation.gestures.draggable import androidx.compose.foundation.gestures.rememberDraggableState import androidx.compose.foundation.background +import androidx.compose.foundation.border import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -46,14 +47,16 @@ import androidx.compose.ui.graphics.Path import androidx.compose.ui.graphics.Shape import androidx.compose.ui.layout.boundsInWindow import androidx.compose.ui.layout.onGloballyPositioned +import androidx.compose.ui.layout.onSizeChanged import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.unit.LayoutDirection +import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.times import kotlinx.coroutines.launch private object FloatingBarDefaults { - val barHeight = 56.dp + val barHeight = 64.dp val horizontalPadding = 16.dp val verticalPadding = 8.dp val menuTopRadius = 28.dp @@ -111,6 +114,7 @@ private class NippleBarShape( fun FloatingBottomNavigationBar( centerContent: @Composable RowScope.() -> Unit, menuOpenProgress: Float, + onBarHeightChanged: (Dp) -> Unit = {}, bottomBarModifier: Modifier = Modifier ) { val density = LocalDensity.current @@ -135,14 +139,19 @@ fun FloatingBottomNavigationBar( ) ) { Surface( - modifier = Modifier.fillMaxWidth().height(FloatingBarDefaults.barHeight), + modifier = + Modifier + .fillMaxWidth() + .onSizeChanged { + onBarHeightChanged(with(density) { it.height.toDp() }) + }, color = MaterialTheme.colorScheme.surfaceContainerLow.copy(alpha = 0.94f), shape = NippleBarShape(protrusionPx = protrusionPx, cornerRadiusPx = cornerRadiusPx), shadowElevation = 10.dp, tonalElevation = 0.dp ) { Row( - modifier = Modifier.fillMaxSize().padding(horizontal = 30.dp, vertical = 5.dp), + modifier = Modifier.fillMaxWidth().padding(horizontal = 20.dp), verticalAlignment = Alignment.CenterVertically ) { centerContent() @@ -168,8 +177,10 @@ fun FloatingBottomNavigationBar( fun FloatingGrowingMenuSheet( sheetState: RevealBottomSheetState, maxAnchor: Float, + barHeight: Dp, menuItems: List, highlightedMenuItem: String?, + activationReadyMenuItem: String?, enableDragToClose: Boolean, onMenuItemLayout: (key: String, rect: Rect) -> Unit, onDismiss: () -> Unit, @@ -182,8 +193,10 @@ fun FloatingGrowingMenuSheet( val density = LocalDensity.current val maxMenuHeight = remember { 560.dp } val maxMenuHeightPx = with(density) { maxMenuHeight.toPx().coerceAtLeast(1f) } - val bottomInset = with(density) { (FloatingBarDefaults.barHeight.toPx() + 8.dp.toPx()).toDp() } + val bottomInset = barHeight + 8.dp val flingCloseThreshold = 220f + val armedIndicatorColor = MaterialTheme.colorScheme.primary.copy(alpha = 0.95f) + val menuItemShape = RoundedCornerShape(28.dp) Box(modifier = modifier.fillMaxSize().padding(horizontal = FloatingBarDefaults.horizontalPadding)) { val openFraction = (sheetState.progress / maxAnchor).coerceIn(0f, 1f) @@ -272,6 +285,7 @@ fun FloatingGrowingMenuSheet( verticalArrangement = Arrangement.spacedBy(8.dp) ) { menuItems.forEach { item -> + val isActivationReady = item.key == activationReadyMenuItem NavigationDrawerItem( selected = item.key == highlightedMenuItem, onClick = { @@ -283,9 +297,21 @@ fun FloatingGrowingMenuSheet( }, icon = { item.icon() }, label = { item.labelContent?.invoke() ?: Text(item.label) }, + shape = menuItemShape, modifier = Modifier .fillMaxWidth() + .then( + if (isActivationReady) { + Modifier.border( + width = 1.5.dp, + color = armedIndicatorColor, + shape = menuItemShape + ) + } else { + Modifier + } + ) .onGloballyPositioned { onMenuItemLayout(item.key, it.boundsInWindow()) } diff --git a/src/daybook_compose/composeApp/src/commonMain/kotlin/org/example/daybook/tables/compact.kt b/src/daybook_compose/composeApp/src/commonMain/kotlin/org/example/daybook/tables/compact.kt index b147c1e1..1dcab2fc 100644 --- a/src/daybook_compose/composeApp/src/commonMain/kotlin/org/example/daybook/tables/compact.kt +++ b/src/daybook_compose/composeApp/src/commonMain/kotlin/org/example/daybook/tables/compact.kt @@ -91,6 +91,7 @@ import androidx.lifecycle.viewmodel.compose.viewModel import androidx.navigation.NavHostController import androidx.navigation.compose.currentBackStackEntryAsState import kotlinx.coroutines.Job +import kotlinx.coroutines.delay import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.launch import org.example.daybook.AppScreens @@ -217,6 +218,7 @@ fun CompactLayout( var highlightedTable by remember { mutableStateOf(null) } var highlightedTab by remember { mutableStateOf(null) } var highlightedMenuItem by remember { mutableStateOf(null) } + var activationReadyMenuItem by remember { mutableStateOf(null) } var isDragging by remember { mutableStateOf(false) } var isLeftDrawerDragging by remember { mutableStateOf(false) } var addButtonWindowRect by remember { mutableStateOf(null) } @@ -230,6 +232,7 @@ fun CompactLayout( val addTabReadyState = addTabController.ready.collectAsState() val addTableReadyState = addTableController.ready.collectAsState() var addTableButtonWindowRect by remember { mutableStateOf(null) } + var floatingBarHeight by remember { mutableStateOf(64.dp) } // feature button layout rects (populated when toolbar renders) var featureButtonLayouts by remember { mutableStateOf(mapOf()) } @@ -290,12 +293,19 @@ fun CompactLayout( .pointerInput(Unit) { var menuSheetOpenedByDrag = false var horizontalDragDistance = 0f + val menuHoverActivationDurationMs = 250L + var hoveredMenuItemKey: String? = null + var hoverActivationJob: Job? = null detectDragGestures( onDragStart = { _ -> isDragging = true isLeftDrawerDragging = true menuSheetOpenedByDrag = false horizontalDragDistance = 0f + hoveredMenuItemKey = null + activationReadyMenuItem = null + hoverActivationJob?.cancel() + hoverActivationJob = null }, onDrag = { change, dragAmount -> if (menuSheetState.isVisible && menuCloseDragEnabled) { @@ -320,7 +330,24 @@ fun CompactLayout( val menuHit = menuItemLayouts.entries.find { (_, rect) -> rect.contains(windowPos) } - highlightedMenuItem = menuHit?.key + val menuHitKey = menuHit?.key + if (menuHitKey != hoveredMenuItemKey) { + hoveredMenuItemKey = menuHitKey + activationReadyMenuItem = null + hoverActivationJob?.cancel() + hoverActivationJob = null + if (menuHitKey != null) { + val candidateKey = menuHitKey + hoverActivationJob = + scope.launch { + delay(menuHoverActivationDurationMs) + if (hoveredMenuItemKey == candidateKey) { + activationReadyMenuItem = candidateKey + } + } + } + } + highlightedMenuItem = menuHitKey // Also update controllers with their target rects for toolbar rollout featureButtonLayouts.forEach { (k, r) -> @@ -359,49 +386,52 @@ fun CompactLayout( } var shouldClose = false - // If released over a menu item, activate it and close + // If released over a menu item that is armed, activate and close. if (highlightedMenuItem != null && lastDragWindowPos != null) { val menuItemKey = highlightedMenuItem val feature = allMenuItems.find { it.key == menuItemKey } - if (feature != null) { + val hoveredLongEnough = menuItemKey != null && activationReadyMenuItem == menuItemKey + if (feature != null && hoveredLongEnough) { feature.onActivate() shouldClose = true } - } else { - // Otherwise, activate any ready feature from toolbar rollout - // Check nav bar features first + } + + // Also allow armed bar/prominent items in the same drag flow. + if (!shouldClose) { navBarFeatureControllers.forEachIndexed { idx, ctrl -> - if (ctrl.ready.value) { + if (ctrl.ready.value && !shouldClose) { val feature = navBarFeatures.getOrNull(idx) if (feature != null) { scope.launch { feature.onActivate() } shouldClose = true } } - ctrl.cancel() } - // Check prominent buttons prominentButtonControllers.forEachIndexed { idx, ctrl -> - if (ctrl.ready.value) { + if (ctrl.ready.value && !shouldClose) { val button = prominentButtons.getOrNull(idx) if (button != null && button.enabled) { scope.launch { button.onClick() } shouldClose = true } } - ctrl.cancel() } } + navBarFeatureControllers.forEach { it.cancel() } + prominentButtonControllers.forEach { it.cancel() } + // Clear highlights highlightedMenuItem = null + activationReadyMenuItem = null lastDragWindowPos = null + hoveredMenuItemKey = null + hoverActivationJob?.cancel() + hoverActivationJob = null isDragging = false menuSheetOpenedByDrag = false - // Cancel all controllers - navBarFeatureControllers.forEach { it.cancel() } - prominentButtonControllers.forEach { it.cancel() } showFeaturesMenu = false // Close sheet if item was activated, otherwise settle to nearest anchor @@ -420,7 +450,11 @@ fun CompactLayout( navBarFeatureControllers.forEach { it.cancel() } prominentButtonControllers.forEach { it.cancel() } highlightedMenuItem = null + activationReadyMenuItem = null lastDragWindowPos = null + hoveredMenuItemKey = null + hoverActivationJob?.cancel() + hoverActivationJob = null isDragging = false menuSheetOpenedByDrag = false menuCloseDragEnabled = false @@ -453,6 +487,7 @@ fun CompactLayout( highlightedTab = null highlightedTable = null highlightedMenuItem = null + activationReadyMenuItem = null } // Ensure sheet snaps to correct anchor when content changes while sheet is open @@ -468,6 +503,7 @@ fun CompactLayout( if (!menuSheetState.isVisible) { menuCloseDragEnabled = false highlightedMenuItem = null + activationReadyMenuItem = null lastDragWindowPos = null } } @@ -546,6 +582,7 @@ fun CompactLayout( centerNavBarContent() }, menuOpenProgress = menuOpenProgress, + onBarHeightChanged = { floatingBarHeight = it }, bottomBarModifier = menuGestureModifier, ) } @@ -743,8 +780,10 @@ fun CompactLayout( FloatingGrowingMenuSheet( sheetState = menuSheetState, maxAnchor = SheetConfig.MENU_MAX_ANCHOR, + barHeight = floatingBarHeight, menuItems = allMenuItems, highlightedMenuItem = highlightedMenuItem, + activationReadyMenuItem = activationReadyMenuItem, enableDragToClose = menuCloseDragEnabled, onMenuItemLayout = { key, rect -> menuItemLayouts = menuItemLayouts + (key to rect) @@ -753,6 +792,7 @@ fun CompactLayout( scope.launch { menuCloseDragEnabled = false highlightedMenuItem = null + activationReadyMenuItem = null lastDragWindowPos = null showFeaturesMenu = false menuSheetState.hide() @@ -764,6 +804,7 @@ fun CompactLayout( } menuCloseDragEnabled = false showFeaturesMenu = false + activationReadyMenuItem = null menuSheetState.hide() }, modifier = Modifier.fillMaxSize() From 70f47fd34940406bdfbc75b7f0501cc8bd565d33 Mon Sep 17 00:00:00 2001 From: dman-os <61868957+dman-os@users.noreply.github.com> Date: Mon, 13 Apr 2026 10:00:46 +0300 Subject: [PATCH 08/16] refactor: floating bottom bar --- .../daybook/capture/CaptureNavActions.kt | 1 - .../daybook/tables/CenterNavBarContent.kt | 15 ++--- .../daybook/uniffi/core/daybook_core.kt | 7 --- .../org/example/daybook/uniffi/daybook_ffi.kt | 63 ------------------- .../daybook/uniffi/types/daybook_types.kt | 1 - x/build-a-dayb.ts | 5 +- 6 files changed, 9 insertions(+), 83 deletions(-) diff --git a/src/daybook_compose/composeApp/src/commonMain/kotlin/org/example/daybook/capture/CaptureNavActions.kt b/src/daybook_compose/composeApp/src/commonMain/kotlin/org/example/daybook/capture/CaptureNavActions.kt index 15b71116..03707f4e 100644 --- a/src/daybook_compose/composeApp/src/commonMain/kotlin/org/example/daybook/capture/CaptureNavActions.kt +++ b/src/daybook_compose/composeApp/src/commonMain/kotlin/org/example/daybook/capture/CaptureNavActions.kt @@ -15,4 +15,3 @@ object CaptureNavActions { _modeCycleRequests.tryEmit(Unit) } } - diff --git a/src/daybook_compose/composeApp/src/commonMain/kotlin/org/example/daybook/tables/CenterNavBarContent.kt b/src/daybook_compose/composeApp/src/commonMain/kotlin/org/example/daybook/tables/CenterNavBarContent.kt index 6989f526..4bc76998 100644 --- a/src/daybook_compose/composeApp/src/commonMain/kotlin/org/example/daybook/tables/CenterNavBarContent.kt +++ b/src/daybook_compose/composeApp/src/commonMain/kotlin/org/example/daybook/tables/CenterNavBarContent.kt @@ -224,6 +224,7 @@ private fun CustomBottomBarItem( Box( modifier = modifier + .clickable(enabled = enabled, onClick = onClick) .padding(vertical = 2.dp), contentAlignment = Alignment.Center ) { @@ -235,7 +236,7 @@ private fun CustomBottomBarItem( Box( modifier = Modifier - .width(104.dp) + .width(56.dp) .clip(itemShape) .background(background) .then( @@ -245,18 +246,12 @@ private fun CustomBottomBarItem( Modifier } ) - .clickable(enabled = enabled, onClick = onClick) - .padding(horizontal = 12.dp, vertical = 6.dp), + .padding(horizontal = 10.dp, vertical = 6.dp), contentAlignment = Alignment.Center ) { - Column( - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.spacedBy(4.dp) - ) { - Box { icon() } - Box { label() } - } + Box { icon() } } + Box { label() } } } } diff --git a/src/daybook_compose/composeApp/src/commonMain/kotlin/org/example/daybook/uniffi/core/daybook_core.kt b/src/daybook_compose/composeApp/src/commonMain/kotlin/org/example/daybook/uniffi/core/daybook_core.kt index 0e882c7d..b3af8aa9 100644 --- a/src/daybook_compose/composeApp/src/commonMain/kotlin/org/example/daybook/uniffi/core/daybook_core.kt +++ b/src/daybook_compose/composeApp/src/commonMain/kotlin/org/example/daybook/uniffi/core/daybook_core.kt @@ -5815,10 +5815,3 @@ public object FfiConverterTypeUuid: FfiConverter { */ public typealias VersionTag = kotlin.String public typealias FfiConverterTypeVersionTag = FfiConverterString - - - - - - - diff --git a/src/daybook_compose/composeApp/src/commonMain/kotlin/org/example/daybook/uniffi/daybook_ffi.kt b/src/daybook_compose/composeApp/src/commonMain/kotlin/org/example/daybook/uniffi/daybook_ffi.kt index 6dd8c03a..9a8ed980 100644 --- a/src/daybook_compose/composeApp/src/commonMain/kotlin/org/example/daybook/uniffi/daybook_ffi.kt +++ b/src/daybook_compose/composeApp/src/commonMain/kotlin/org/example/daybook/uniffi/daybook_ffi.kt @@ -11528,66 +11528,3 @@ public object FfiConverterTypeUuid: FfiConverter { FfiConverterByteArray.write(builtinValue, buf) } } - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/src/daybook_compose/composeApp/src/commonMain/kotlin/org/example/daybook/uniffi/types/daybook_types.kt b/src/daybook_compose/composeApp/src/commonMain/kotlin/org/example/daybook/uniffi/types/daybook_types.kt index 6ce412db..e832dddd 100644 --- a/src/daybook_compose/composeApp/src/commonMain/kotlin/org/example/daybook/uniffi/types/daybook_types.kt +++ b/src/daybook_compose/composeApp/src/commonMain/kotlin/org/example/daybook/uniffi/types/daybook_types.kt @@ -3243,4 +3243,3 @@ public object FfiConverterTypeUuid: FfiConverter { FfiConverterByteArray.write(builtinValue, buf) } } - diff --git a/x/build-a-dayb.ts b/x/build-a-dayb.ts index c1a1dd0e..b66c3ae9 100755 --- a/x/build-a-dayb.ts +++ b/x/build-a-dayb.ts @@ -72,7 +72,10 @@ const ortRootDir = $.relativeDir("../target/ort"); const sourceArchivePath = ortRootDir.join(`onnxruntime-${ortSourceTag}.tar.gz`); const sourceDir = ortRootDir.join(`onnxruntime-src-${ortSourceTag}`); const sourceCompleteFile = ortRootDir.join(`.source-${ortSourceTag}.complete`); -const fetchcontentCacheDir = ortRootDir.join("fetchcontent-cache", ortSourceTag); +const fetchcontentCacheDir = ortRootDir.join( + "fetchcontent-cache", + ortSourceTag, +); const distDir = ortRootDir.join( "dist", ortSourceTag, From 2fbe54e0d41888b99c862ad95f0c319e72387581 Mon Sep 17 00:00:00 2001 From: dman-os <61868957+dman-os@users.noreply.github.com> Date: Mon, 13 Apr 2026 18:44:14 +0300 Subject: [PATCH 09/16] fix: address feedback --- README.md | 2 +- src/daybook_cli/main.rs | 1 + .../composeApp/build.gradle.kts | 6 +- .../org/example/daybook/MainActivity.kt | 2 +- .../kotlin/org/example/daybook/App.kt | 45 ++++++++-- .../daybook/DocEditorStoreViewModel.kt | 16 +++- .../daybook/capture/screens/CaptureScreen.kt | 46 +++++++--- .../daybook/progress/ProgressScreen.kt | 27 ++++-- .../daybook/tables/CenterNavBarContent.kt | 10 ++- .../org/example/daybook/tables/compact.kt | 4 +- .../org/example/daybook/tables/expanded.kt | 6 +- .../org/example/daybook/ui/DocEditor.kt | 84 ++++++++++--------- .../org/example/daybook/welcome/helpers.kt | 10 +-- .../org/example/daybook/welcome/welcome.kt | 50 ++++++----- .../kotlin/org/example/daybook/main.kt | 1 + src/daybook_core/rt/init.rs | 5 ++ 16 files changed, 216 insertions(+), 99 deletions(-) diff --git a/README.md b/README.md index 23364788..0c6a0466 100644 --- a/README.md +++ b/README.md @@ -14,7 +14,7 @@ Experimental. > #### what's in the oven ‍‍👩🏿‍🍳? > > - Our first feature! -> - Agent sanboxing/orchestration. +> - Agent sandboxing/orchestration. ## 🌞 Daybook diff --git a/src/daybook_cli/main.rs b/src/daybook_cli/main.rs index 9da3c347..ddb0241f 100644 --- a/src/daybook_cli/main.rs +++ b/src/daybook_cli/main.rs @@ -1540,6 +1540,7 @@ mod lazy { let (rt, stop) = daybook_core::rt::Rt::boot( daybook_core::rt::RtConfig { device_id: "main_TODO_XXX".into(), + startup_progress_task_id: None, }, ctx.doc_app.document_id().clone(), format!("sqlite://{}", ctx.layout.sqlite_path.display()), diff --git a/src/daybook_compose/composeApp/build.gradle.kts b/src/daybook_compose/composeApp/build.gradle.kts index 77fadd6e..fe3c9498 100644 --- a/src/daybook_compose/composeApp/build.gradle.kts +++ b/src/daybook_compose/composeApp/build.gradle.kts @@ -395,8 +395,10 @@ fun registerRustAndroidCopyTask( into(destDir) val sourceSoFile = File(cargoTargetDir, sourceLibPath.removePrefix("target/")) - if (!sourceSoFile.exists()) { - throw GradleException("Missing Rust Android library: ${sourceSoFile.absolutePath}") + doFirst { + if (!sourceSoFile.exists()) { + throw GradleException("Missing Rust Android library: ${sourceSoFile.absolutePath}") + } } from(sourceSoFile) diff --git a/src/daybook_compose/composeApp/src/androidMain/kotlin/org/example/daybook/MainActivity.kt b/src/daybook_compose/composeApp/src/androidMain/kotlin/org/example/daybook/MainActivity.kt index 5560f0cd..3e4cd621 100644 --- a/src/daybook_compose/composeApp/src/androidMain/kotlin/org/example/daybook/MainActivity.kt +++ b/src/daybook_compose/composeApp/src/androidMain/kotlin/org/example/daybook/MainActivity.kt @@ -239,7 +239,7 @@ fun AndroidApp() { }, shutdownRequested = shutdownRequested, onShutdownCompleted = {}, - autoShutdownOnDispose = false + autoShutdownOnDispose = true ) } } diff --git a/src/daybook_compose/composeApp/src/commonMain/kotlin/org/example/daybook/App.kt b/src/daybook_compose/composeApp/src/commonMain/kotlin/org/example/daybook/App.kt index 99157afd..6fc0ea5e 100644 --- a/src/daybook_compose/composeApp/src/commonMain/kotlin/org/example/daybook/App.kt +++ b/src/daybook_compose/composeApp/src/commonMain/kotlin/org/example/daybook/App.kt @@ -854,7 +854,6 @@ fun App( withStartupStage("warmUpTablesRepo", startupMark, startupProgress) { warmUpTablesRepo(tablesRepo ?: error("tables repo failed to load")) } - startupProgress.complete(startupMark.elapsedNow().toString()) AppContainer( ffiCtx = fcxReady, drawerRepo = drawerRepo ?: error("drawer repo failed to load"), @@ -913,9 +912,10 @@ fun App( val current = ready.container if (current.syncRepo != null && current.rtFfi != null) return@LaunchedEffect + var loadedSyncRepo: SyncRepoFfi? = null try { println("[APP_INIT] stage=deferred SyncRepoFfi.load start") - val syncRepo = withContext(Dispatchers.IO) { + loadedSyncRepo = withContext(Dispatchers.IO) { SyncRepoFfi.load( fcx = current.ffiCtx, configRepo = current.configRepo, @@ -941,9 +941,42 @@ fun App( ) } println("[APP_INIT] stage=deferred RtFfi.load done") - initState = AppInitState.Ready(current.copy(syncRepo = syncRepo, rtFfi = rtFfi)) + withContext(Dispatchers.IO) { + current.progressRepo.addUpdate( + STARTUP_PROGRESS_TASK_ID, + ProgressUpdate( + at = Clock.System.now(), + title = "App startup", + deets = + ProgressUpdateDeets.Completed( + state = ProgressFinalState.SUCCEEDED, + message = + "startup complete (from_app_start_ms=${appStartMark.elapsedNow().inWholeMilliseconds})" + ) + ) + ) + } + initState = AppInitState.Ready(current.copy(syncRepo = loadedSyncRepo, rtFfi = rtFfi)) } catch (throwable: Throwable) { if (throwable is CancellationException) throw throwable + runCatching { + withContext(Dispatchers.IO) { + loadedSyncRepo?.close() + current.progressRepo.addUpdate( + STARTUP_PROGRESS_TASK_ID, + ProgressUpdate( + at = Clock.System.now(), + title = "App startup", + deets = + ProgressUpdateDeets.Completed( + state = ProgressFinalState.FAILED, + message = + "startup failed during deferred runtime load: ${throwable.message ?: "unknown error"} (from_app_start_ms=${appStartMark.elapsedNow().inWholeMilliseconds})" + ) + ) + ) + } + } initState = AppInitState.Error(throwable) } } @@ -1004,9 +1037,11 @@ fun App( is AppInitState.Ready -> { val appContainer = state.container - val drawerVm: DrawerViewModel = viewModel { DrawerViewModel(appContainer.drawerRepo) } + val containerKey = "container:${appContainer.ffiCtx}" + val drawerVm: DrawerViewModel = + viewModel(key = "drawerVm:$containerKey") { DrawerViewModel(appContainer.drawerRepo) } val docEditorStore: DocEditorStoreViewModel = - viewModel { DocEditorStoreViewModel(appContainer.drawerRepo) } + viewModel(key = "docEditorStoreVm:$containerKey") { DocEditorStoreViewModel(appContainer.drawerRepo) } var shutdownDone by remember(appContainer.ffiCtx) { mutableStateOf(false) } LaunchedEffect(shutdownRequested, appContainer.ffiCtx, shutdownDone) { diff --git a/src/daybook_compose/composeApp/src/commonMain/kotlin/org/example/daybook/DocEditorStoreViewModel.kt b/src/daybook_compose/composeApp/src/commonMain/kotlin/org/example/daybook/DocEditorStoreViewModel.kt index b832780b..46da18c3 100644 --- a/src/daybook_compose/composeApp/src/commonMain/kotlin/org/example/daybook/DocEditorStoreViewModel.kt +++ b/src/daybook_compose/composeApp/src/commonMain/kotlin/org/example/daybook/DocEditorStoreViewModel.kt @@ -4,9 +4,11 @@ package org.example.daybook import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.Job import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.isActive import kotlinx.coroutines.launch import kotlin.time.Clock import kotlin.time.Duration.Companion.minutes @@ -35,6 +37,7 @@ class DocEditorStoreViewModel( val selectedController = _selectedController.asStateFlow() private var listenerRegistration: ListenerRegistration? = null + private var registerJob: Job? = null private val evictionTtlMs = 10.minutes.inWholeMilliseconds private val listener = @@ -59,9 +62,15 @@ class DocEditorStoreViewModel( } init { - viewModelScope.launch { - listenerRegistration = drawerRepo.ffiRegisterListener(listener) - } + registerJob = + viewModelScope.launch { + val registration = drawerRepo.ffiRegisterListener(listener) + if (!isActive) { + registration.unregister() + return@launch + } + listenerRegistration = registration + } viewModelScope.launch { while (true) { delay(30_000) @@ -143,6 +152,7 @@ class DocEditorStoreViewModel( private fun nowMs(): Long = Clock.System.now().toEpochMilliseconds() override fun onCleared() { + registerJob?.cancel() listenerRegistration?.unregister() super.onCleared() } diff --git a/src/daybook_compose/composeApp/src/commonMain/kotlin/org/example/daybook/capture/screens/CaptureScreen.kt b/src/daybook_compose/composeApp/src/commonMain/kotlin/org/example/daybook/capture/screens/CaptureScreen.kt index 69dbecc2..0d52ae06 100644 --- a/src/daybook_compose/composeApp/src/commonMain/kotlin/org/example/daybook/capture/screens/CaptureScreen.kt +++ b/src/daybook_compose/composeApp/src/commonMain/kotlin/org/example/daybook/capture/screens/CaptureScreen.kt @@ -95,21 +95,43 @@ class CaptureScreenViewModel( private fun persistCaptureMode(mode: CaptureMode) { viewModelScope.launch { - val state = tablesVm.tablesState.value - val selectedTableId = tablesVm.selectedTableId.value - if (state is TablesState.Data && selectedTableId != null) { - val windowId = - state.tables[selectedTableId]?.window?.let { windowPolicy -> - when (windowPolicy) { - is TableWindow.Specific -> windowPolicy.id - is TableWindow.AllWindows -> state.windows.keys.firstOrNull() + try { + val state = tablesVm.tablesState.value + val selectedTableId = tablesVm.selectedTableId.value + if (state is TablesState.Data && selectedTableId != null) { + val windowId = + state.tables[selectedTableId]?.window?.let { windowPolicy -> + when (windowPolicy) { + is TableWindow.Specific -> windowPolicy.id + is TableWindow.AllWindows -> state.windows.keys.firstOrNull() + } + } + windowId?.let { id -> + state.windows[id]?.let { window -> + tablesRepo.setWindow(id, window.copy(lastCaptureMode = mode)) } - } - windowId?.let { id -> - state.windows[id]?.let { window -> - tablesRepo.setWindow(id, window.copy(lastCaptureMode = mode)) } } + } catch (e: FfiException) { + val selectedTableId = tablesVm.selectedTableId.value + val state = tablesVm.tablesState.value + val windowId = + if (state is TablesState.Data && selectedTableId != null) { + state.tables[selectedTableId]?.window?.let { windowPolicy -> + when (windowPolicy) { + is TableWindow.Specific -> windowPolicy.id + is TableWindow.AllWindows -> state.windows.keys.firstOrNull() + } + } + } else { + null + } + println( + "[CAPTURE] persistCaptureMode failed mode=$mode selectedTableId=$selectedTableId windowId=$windowId err=${e.message}" + ) + _message.value = "Failed to persist capture mode" + } catch (t: Throwable) { + throw t } } } diff --git a/src/daybook_compose/composeApp/src/commonMain/kotlin/org/example/daybook/progress/ProgressScreen.kt b/src/daybook_compose/composeApp/src/commonMain/kotlin/org/example/daybook/progress/ProgressScreen.kt index 990ba617..66465a38 100644 --- a/src/daybook_compose/composeApp/src/commonMain/kotlin/org/example/daybook/progress/ProgressScreen.kt +++ b/src/daybook_compose/composeApp/src/commonMain/kotlin/org/example/daybook/progress/ProgressScreen.kt @@ -7,6 +7,7 @@ import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxHeight @@ -44,6 +45,7 @@ import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue @@ -52,6 +54,8 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.Modifier import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.layout.onSizeChanged +import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.unit.dp import androidx.lifecycle.viewmodel.compose.viewModel import kotlinx.coroutines.delay @@ -337,6 +341,8 @@ private fun ProgressDetailScreen( val listState = rememberLazyListState() val timelineHeaderIndex = 2 + (if (task.tags.isNotEmpty()) 1 else 0) + (if (amountEntry != null) 1 else 0) + val density = LocalDensity.current + var pinnedHeaderHeightPx by remember { mutableIntStateOf(0) } val showPinnedTimelineHeader by remember(listState, timelineHeaderIndex) { derivedStateOf { listState.firstVisibleItemIndex > timelineHeaderIndex || @@ -354,6 +360,12 @@ private fun ProgressDetailScreen( modifier = Modifier .fillMaxSize() .padding(12.dp), + contentPadding = + if (showPinnedTimelineHeader) { + PaddingValues(top = with(density) { pinnedHeaderHeightPx.toDp() }) + } else { + PaddingValues(0.dp) + }, verticalArrangement = Arrangement.spacedBy(8.dp) ) { val typeInfo = progressTypeInfo(task) @@ -467,7 +479,11 @@ private fun ProgressDetailScreen( if (showPinnedTimelineHeader) { Surface( color = MaterialTheme.colorScheme.surface, - modifier = Modifier.fillMaxWidth().align(Alignment.TopStart) + modifier = + Modifier + .fillMaxWidth() + .align(Alignment.TopStart) + .onSizeChanged { pinnedHeaderHeightPx = it.height } ) { Column(modifier = Modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 12.dp)) { Text("Timeline", style = MaterialTheme.typography.titleSmall) @@ -566,14 +582,9 @@ private fun progressTypeInfo(task: ProgressTask): ProgressTypeInfo { } private fun latestAmountEntry(task: ProgressTask, updates: List): ProgressUpdateEntry? { - val latestFromTimeline = updates.lastOrNull { it.update.deets is ProgressUpdateDeets.Amount } + val amountUpdates = updates.filter { it.update.deets is ProgressUpdateDeets.Amount } val latestTask = task.latestUpdate?.takeIf { it.update.deets is ProgressUpdateDeets.Amount } - return when { - latestTask == null -> latestFromTimeline - latestFromTimeline == null -> latestTask - latestTask.sequence >= latestFromTimeline.sequence -> latestTask - else -> latestFromTimeline - } + return (amountUpdates + listOfNotNull(latestTask)).maxByOrNull { it.sequence } } private fun formatClock(unixSecs: Long): String { diff --git a/src/daybook_compose/composeApp/src/commonMain/kotlin/org/example/daybook/tables/CenterNavBarContent.kt b/src/daybook_compose/composeApp/src/commonMain/kotlin/org/example/daybook/tables/CenterNavBarContent.kt index 4bc76998..fb9dbe2f 100644 --- a/src/daybook_compose/composeApp/src/commonMain/kotlin/org/example/daybook/tables/CenterNavBarContent.kt +++ b/src/daybook_compose/composeApp/src/commonMain/kotlin/org/example/daybook/tables/CenterNavBarContent.kt @@ -14,6 +14,7 @@ import androidx.compose.animation.slideOutHorizontally import androidx.compose.foundation.background import androidx.compose.foundation.border import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -30,6 +31,7 @@ import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -214,6 +216,7 @@ private fun CustomBottomBarItem( modifier: Modifier = Modifier ) { val itemShape = RoundedCornerShape(20.dp) + val outerInteraction = remember { MutableInteractionSource() } val background = when { selected -> selectedFill @@ -224,7 +227,12 @@ private fun CustomBottomBarItem( Box( modifier = modifier - .clickable(enabled = enabled, onClick = onClick) + .clickable( + enabled = enabled, + interactionSource = outerInteraction, + indication = null, + onClick = onClick + ) .padding(vertical = 2.dp), contentAlignment = Alignment.Center ) { diff --git a/src/daybook_compose/composeApp/src/commonMain/kotlin/org/example/daybook/tables/compact.kt b/src/daybook_compose/composeApp/src/commonMain/kotlin/org/example/daybook/tables/compact.kt index 1dcab2fc..7d1732d9 100644 --- a/src/daybook_compose/composeApp/src/commonMain/kotlin/org/example/daybook/tables/compact.kt +++ b/src/daybook_compose/composeApp/src/commonMain/kotlin/org/example/daybook/tables/compact.kt @@ -391,7 +391,7 @@ fun CompactLayout( val menuItemKey = highlightedMenuItem val feature = allMenuItems.find { it.key == menuItemKey } val hoveredLongEnough = menuItemKey != null && activationReadyMenuItem == menuItemKey - if (feature != null && hoveredLongEnough) { + if (feature != null && feature.enabled && hoveredLongEnough) { feature.onActivate() shouldClose = true } @@ -402,7 +402,7 @@ fun CompactLayout( navBarFeatureControllers.forEachIndexed { idx, ctrl -> if (ctrl.ready.value && !shouldClose) { val feature = navBarFeatures.getOrNull(idx) - if (feature != null) { + if (feature != null && feature.enabled) { scope.launch { feature.onActivate() } shouldClose = true } diff --git a/src/daybook_compose/composeApp/src/commonMain/kotlin/org/example/daybook/tables/expanded.kt b/src/daybook_compose/composeApp/src/commonMain/kotlin/org/example/daybook/tables/expanded.kt index f8347089..c8503f6e 100644 --- a/src/daybook_compose/composeApp/src/commonMain/kotlin/org/example/daybook/tables/expanded.kt +++ b/src/daybook_compose/composeApp/src/commonMain/kotlin/org/example/daybook/tables/expanded.kt @@ -682,8 +682,10 @@ fun SidebarContent(navController: NavHostController, modifier: Modifier = Modifi NavigationRailItem( selected = isSelected, onClick = { - scope.launch { - item.onActivate() + if (item.enabled) { + scope.launch { + item.onActivate() + } } }, enabled = item.enabled, diff --git a/src/daybook_compose/composeApp/src/commonMain/kotlin/org/example/daybook/ui/DocEditor.kt b/src/daybook_compose/composeApp/src/commonMain/kotlin/org/example/daybook/ui/DocEditor.kt index 28ec09d6..8b39610a 100644 --- a/src/daybook_compose/composeApp/src/commonMain/kotlin/org/example/daybook/ui/DocEditor.kt +++ b/src/daybook_compose/composeApp/src/commonMain/kotlin/org/example/daybook/ui/DocEditor.kt @@ -142,6 +142,7 @@ fun DocEditor( color = MaterialTheme.colorScheme.onSurfaceVariant, ) } + EditorSaveStatusIndicator(saveStatus = saveStatus, modifier = Modifier.fillMaxWidth()) HorizontalDivider(modifier = Modifier.padding(vertical = 8.dp)) BoxWithConstraints(modifier = Modifier.weight(1f).fillMaxWidth()) { @@ -169,7 +170,6 @@ fun DocEditor( descriptor = descriptor, doc = state.doc, controller = controller, - saveStatus = saveStatus, modifier = Modifier.bringIntoViewRequester(bringIntoViewRequester), canShowMenu = state.docId != null, noteDraft = state.noteEditors[descriptor.facetKey]?.draft, @@ -214,7 +214,6 @@ private fun FacetListItem( descriptor: FacetViewDescriptor, doc: Doc?, controller: EditorSessionController, - saveStatus: EditorSaveStatus, modifier: Modifier = Modifier, canShowMenu: Boolean, noteDraft: String?, @@ -228,7 +227,6 @@ private fun FacetListItem( Column(modifier = modifier.fillMaxWidth()) { FacetHeader( descriptor = descriptor, - saveStatus = saveStatus, canShowMenu = canShowMenu, onAddNote = { controller.addNoteFacetAfter(descriptor.facetKey) }, onMakePrimary = { controller.makeFacetPrimary(descriptor.facetKey) }, @@ -288,7 +286,6 @@ private fun FacetListItem( @Composable private fun FacetHeader( descriptor: FacetViewDescriptor, - saveStatus: EditorSaveStatus, canShowMenu: Boolean, onAddNote: () -> Unit, onMakePrimary: () -> Unit, @@ -297,22 +294,6 @@ private fun FacetHeader( canMoveUp: Boolean, canMoveDown: Boolean, ) { - val saveStatusUi = - when (saveStatus) { - EditorSaveStatus.Idle -> null - EditorSaveStatus.Saving -> - SaveStatusUi( - icon = Icons.Filled.Sync, - tint = MaterialTheme.colorScheme.primary, - label = "Saving" - ) - EditorSaveStatus.Error -> - SaveStatusUi( - icon = Icons.Filled.Error, - tint = MaterialTheme.colorScheme.error, - label = "Save failed" - ) - } val interactionSource = remember { MutableInteractionSource() } val isHovered by interactionSource.collectIsHoveredAsState() Row( @@ -330,24 +311,6 @@ private fun FacetHeader( Row( verticalAlignment = Alignment.CenterVertically, ) { - if (saveStatusUi != null) { - TooltipBox( - positionProvider = TooltipDefaults.rememberPlainTooltipPositionProvider(), - tooltip = { - PlainTooltip { - Text(saveStatusUi.label) - } - }, - state = rememberTooltipState(), - ) { - Icon( - imageVector = saveStatusUi.icon, - contentDescription = saveStatusUi.label, - tint = saveStatusUi.tint, - ) - } - Spacer(modifier = Modifier.width(4.dp)) - } if (showPriorityActions) { FacetActionIconButton( label = if (descriptor.isPrimary) "Facet is primary" else "Make this facet primary", @@ -402,6 +365,51 @@ private fun FacetHeader( } } +@Composable +private fun toSaveStatusUi(saveStatus: EditorSaveStatus): SaveStatusUi? = + when (saveStatus) { + EditorSaveStatus.Idle -> null + EditorSaveStatus.Saving -> + SaveStatusUi( + icon = Icons.Filled.Sync, + tint = MaterialTheme.colorScheme.primary, + label = "Saving" + ) + EditorSaveStatus.Error -> + SaveStatusUi( + icon = Icons.Filled.Error, + tint = MaterialTheme.colorScheme.error, + label = "Save failed" + ) + } + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun EditorSaveStatusIndicator(saveStatus: EditorSaveStatus, modifier: Modifier = Modifier) { + val saveStatusUi = toSaveStatusUi(saveStatus) ?: return + Row( + modifier = modifier.padding(horizontal = 12.dp, vertical = 4.dp), + horizontalArrangement = Arrangement.End, + verticalAlignment = Alignment.CenterVertically + ) { + TooltipBox( + positionProvider = TooltipDefaults.rememberPlainTooltipPositionProvider(), + tooltip = { + PlainTooltip { + Text(saveStatusUi.label) + } + }, + state = rememberTooltipState(), + ) { + Icon( + imageVector = saveStatusUi.icon, + contentDescription = saveStatusUi.label, + tint = saveStatusUi.tint, + ) + } + } +} + @Composable @OptIn(ExperimentalMaterial3Api::class) private fun FacetActionIconButton( diff --git a/src/daybook_compose/composeApp/src/commonMain/kotlin/org/example/daybook/welcome/helpers.kt b/src/daybook_compose/composeApp/src/commonMain/kotlin/org/example/daybook/welcome/helpers.kt index f2ad3dd0..f31f9d8b 100644 --- a/src/daybook_compose/composeApp/src/commonMain/kotlin/org/example/daybook/welcome/helpers.kt +++ b/src/daybook_compose/composeApp/src/commonMain/kotlin/org/example/daybook/welcome/helpers.kt @@ -6,11 +6,11 @@ import org.example.daybook.uniffi.AppFfiCtx import org.example.daybook.uniffi.FfiException internal suspend inline fun withAppFfiCtx(crossinline block: suspend (AppFfiCtx) -> T): T { - return withContext(Dispatchers.IO) { - val gcx = AppFfiCtx.init() - try { - block(gcx) - } finally { + val gcx = withContext(Dispatchers.IO) { AppFfiCtx.init() } + return try { + block(gcx) + } finally { + withContext(Dispatchers.IO) { gcx.close() } } diff --git a/src/daybook_compose/composeApp/src/commonMain/kotlin/org/example/daybook/welcome/welcome.kt b/src/daybook_compose/composeApp/src/commonMain/kotlin/org/example/daybook/welcome/welcome.kt index 87efc6b8..ad88192d 100644 --- a/src/daybook_compose/composeApp/src/commonMain/kotlin/org/example/daybook/welcome/welcome.kt +++ b/src/daybook_compose/composeApp/src/commonMain/kotlin/org/example/daybook/welcome/welcome.kt @@ -1087,7 +1087,7 @@ private fun CloneQrScannerScreen( } } val analyzerReady = analyzer - if (analyzerReady == null) { + if (!useNativePreviewQr && analyzerReady == null) { Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { Column( horizontalAlignment = Alignment.CenterHorizontally, @@ -1104,21 +1104,23 @@ private fun CloneQrScannerScreen( var hasCompleted by remember { mutableStateOf(false) } val frameBridge = remember(analyzerReady) { - CameraQrOverlayBridge( - analyzer = analyzerReady, - onDetectedText = { rawText -> - uiScope.launch { - if (hasCompleted) return@launch - val candidate = rawText.trim() - if (!looksLikeUrl(candidate)) { - userVisibleError = "Detected QR is not a URL." - return@launch + analyzerReady?.let { readyAnalyzer -> + CameraQrOverlayBridge( + analyzer = readyAnalyzer, + onDetectedText = { rawText -> + uiScope.launch { + if (hasCompleted) return@launch + val candidate = rawText.trim() + if (!looksLikeUrl(candidate)) { + userVisibleError = "Detected QR is not a URL." + return@launch + } + hasCompleted = true + onDetectedUrl(candidate) } - hasCompleted = true - onDetectedUrl(candidate) } - } - ) + ) + } } val previewBridge = remember(cameraPreviewFfi) { @@ -1138,18 +1140,23 @@ private fun CloneQrScannerScreen( } ) } - val overlayState by (if (useNativePreviewQr) previewBridge.state else frameBridge.state).collectAsState() + val overlayState by + (if (useNativePreviewQr) { + previewBridge.state + } else { + frameBridge?.state ?: previewBridge.state + }).collectAsState() androidx.compose.runtime.DisposableEffect(analyzerReady, frameBridge, previewBridge, useNativePreviewQr) { if (useNativePreviewQr) { previewBridge.start() } else { - frameBridge.start() + frameBridge?.start() } onDispose { previewBridge.stop() - frameBridge.stop() - analyzerReady.close() + frameBridge?.stop() + analyzerReady?.close() } } @@ -1168,7 +1175,12 @@ private fun CloneQrScannerScreen( } else { overlayState.overlays }, - onFrameAvailable = if (useNativePreviewQr) null else frameBridge::submitFrame + onFrameAvailable = + if (useNativePreviewQr) { + null + } else { + frameBridge?.let { bridge -> { sample -> bridge.submitFrame(sample) } } + } ) } val errorText = userVisibleError ?: overlayState.latestError diff --git a/src/daybook_compose/composeApp/src/desktopMain/kotlin/org/example/daybook/main.kt b/src/daybook_compose/composeApp/src/desktopMain/kotlin/org/example/daybook/main.kt index 29a0e86a..e7b73d9c 100644 --- a/src/daybook_compose/composeApp/src/desktopMain/kotlin/org/example/daybook/main.kt +++ b/src/daybook_compose/composeApp/src/desktopMain/kotlin/org/example/daybook/main.kt @@ -30,6 +30,7 @@ private fun installSignalHandler(signalName: String) { println("[APP_SHUTDOWN] installed signal handler name=$signalName") }.onFailure { error -> println("[APP_SHUTDOWN] failed to install signal handler name=$signalName err=${error.message}") + throw error } } diff --git a/src/daybook_core/rt/init.rs b/src/daybook_core/rt/init.rs index a2b01660..ee4072be 100644 --- a/src/daybook_core/rt/init.rs +++ b/src/daybook_core/rt/init.rs @@ -406,5 +406,10 @@ impl InitRepo { }, ) .await + .wrap_err_with(|| { + format!( + "failed to add_update for {plug_id}/{init_key} {stage} task_id={task_id}" + ) + }) } } From e2fb08887e5e46edfda03fcef7a1b68fa32aef99 Mon Sep 17 00:00:00 2001 From: dman-os <61868957+dman-os@users.noreply.github.com> Date: Mon, 13 Apr 2026 19:56:28 +0300 Subject: [PATCH 10/16] fix: address feedback --- .../daybook/DocEditorStoreViewModel.kt | 11 ++----- .../daybook/capture/screens/CaptureScreen.kt | 28 +++++++---------- .../daybook/tables/CenterNavBarContent.kt | 12 +------ .../org/example/daybook/tables/Features.kt | 11 +++++++ .../org/example/daybook/welcome/welcome.kt | 31 ++++++++++++------- .../kotlin/org/example/daybook/main.kt | 3 ++ 6 files changed, 49 insertions(+), 47 deletions(-) diff --git a/src/daybook_compose/composeApp/src/commonMain/kotlin/org/example/daybook/DocEditorStoreViewModel.kt b/src/daybook_compose/composeApp/src/commonMain/kotlin/org/example/daybook/DocEditorStoreViewModel.kt index 46da18c3..8da69c6c 100644 --- a/src/daybook_compose/composeApp/src/commonMain/kotlin/org/example/daybook/DocEditorStoreViewModel.kt +++ b/src/daybook_compose/composeApp/src/commonMain/kotlin/org/example/daybook/DocEditorStoreViewModel.kt @@ -15,7 +15,6 @@ import kotlin.time.Duration.Companion.minutes import org.example.daybook.ui.editor.EditorSessionController import org.example.daybook.uniffi.DrawerEventListener import org.example.daybook.uniffi.DrawerRepoFfi -import org.example.daybook.uniffi.FfiException import org.example.daybook.uniffi.core.DrawerEvent import org.example.daybook.uniffi.core.ListenerRegistration @@ -118,13 +117,9 @@ class DocEditorStoreViewModel( private suspend fun refreshDoc(docId: String) { val entry = sessions[docId] ?: return - try { - val bundle = drawerRepo.getBundle(docId, "main") - entry.controller.bindDoc(bundle?.doc, bundle) - entry.lastTouchedMs = nowMs() - } catch (e: FfiException) { - throw e - } + val bundle = drawerRepo.getBundle(docId, "main") + entry.controller.bindDoc(bundle?.doc, bundle) + entry.lastTouchedMs = nowMs() } private fun evictIdleSessions() { diff --git a/src/daybook_compose/composeApp/src/commonMain/kotlin/org/example/daybook/capture/screens/CaptureScreen.kt b/src/daybook_compose/composeApp/src/commonMain/kotlin/org/example/daybook/capture/screens/CaptureScreen.kt index 0d52ae06..5e95078a 100644 --- a/src/daybook_compose/composeApp/src/commonMain/kotlin/org/example/daybook/capture/screens/CaptureScreen.kt +++ b/src/daybook_compose/composeApp/src/commonMain/kotlin/org/example/daybook/capture/screens/CaptureScreen.kt @@ -16,7 +16,10 @@ import androidx.compose.ui.unit.dp import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewmodel.compose.viewModel +import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.launch import org.example.daybook.ChromeState @@ -63,8 +66,8 @@ class CaptureScreenViewModel( private val _currentDoc = MutableStateFlow(null) val currentDoc = _currentDoc.asStateFlow() - private val _message = MutableStateFlow(null) - val message = _message.asStateFlow() + private val _message = MutableSharedFlow(extraBufferCapacity = 1, replay = 0) + val message: SharedFlow = _message.asSharedFlow() val editorController = EditorSessionController( @@ -89,8 +92,7 @@ class CaptureScreenViewModel( CaptureMode.CAMERA -> CaptureMode.MIC CaptureMode.MIC -> CaptureMode.TEXT } - _captureMode.value = next - persistCaptureMode(next) + setCaptureMode(next) } private fun persistCaptureMode(mode: CaptureMode) { @@ -129,7 +131,7 @@ class CaptureScreenViewModel( println( "[CAPTURE] persistCaptureMode failed mode=$mode selectedTableId=$selectedTableId windowId=$windowId err=${e.message}" ) - _message.value = "Failed to persist capture mode" + _message.tryEmit("Failed to persist capture mode") } catch (t: Throwable) { throw t } @@ -174,18 +176,14 @@ class CaptureScreenViewModel( ) drawerRepo.add(args) - _message.value = "Photo saved successfully" + _message.tryEmit("Photo saved successfully") } catch (e: FfiException) { println("Error saving image: $e") - _message.value = "Error saving photo: ${e.message}" + _message.tryEmit("Error saving photo: ${e.message}") } } } - fun clearMessage() { - _message.value = null - } - fun loadDoc(id: String) { viewModelScope.launch { val doc = drawerRepo.get(id, "main") @@ -300,12 +298,10 @@ fun CaptureScreen(modifier: Modifier = Modifier, initialDocId: String? = null) { } val snackbarHostState = remember { SnackbarHostState() } - val message by vm.message.collectAsState() - LaunchedEffect(message) { - message?.let { - snackbarHostState.showSnackbar(it) - vm.clearMessage() + LaunchedEffect(vm) { + vm.message.collect { msg -> + snackbarHostState.showSnackbar(msg) } } diff --git a/src/daybook_compose/composeApp/src/commonMain/kotlin/org/example/daybook/tables/CenterNavBarContent.kt b/src/daybook_compose/composeApp/src/commonMain/kotlin/org/example/daybook/tables/CenterNavBarContent.kt index fb9dbe2f..e6124142 100644 --- a/src/daybook_compose/composeApp/src/commonMain/kotlin/org/example/daybook/tables/CenterNavBarContent.kt +++ b/src/daybook_compose/composeApp/src/commonMain/kotlin/org/example/daybook/tables/CenterNavBarContent.kt @@ -43,7 +43,6 @@ import androidx.compose.ui.unit.dp import androidx.navigation.NavHostController import androidx.navigation.compose.currentBackStackEntryAsState import kotlinx.coroutines.launch -import org.example.daybook.AppScreens import org.example.daybook.LocalChromeStateManager import org.example.daybook.MainFeatureActionButton @@ -265,15 +264,6 @@ private fun CustomBottomBarItem( } private fun isFeatureRouteSelected(featureKey: String, currentRoute: String?): Boolean { - val targetRoute = - when (featureKey) { - FeatureKeys.Home -> AppScreens.Home.name - FeatureKeys.Capture -> AppScreens.Capture.name - FeatureKeys.Drawer -> AppScreens.Drawer.name - FeatureKeys.Tables -> AppScreens.Tables.name - FeatureKeys.Progress -> AppScreens.Progress.name - FeatureKeys.Settings -> AppScreens.Settings.name - else -> null - } + val targetRoute = routeForFeatureKey(featureKey) return targetRoute != null && targetRoute == currentRoute } diff --git a/src/daybook_compose/composeApp/src/commonMain/kotlin/org/example/daybook/tables/Features.kt b/src/daybook_compose/composeApp/src/commonMain/kotlin/org/example/daybook/tables/Features.kt index 3aa23837..d102304d 100644 --- a/src/daybook_compose/composeApp/src/commonMain/kotlin/org/example/daybook/tables/Features.kt +++ b/src/daybook_compose/composeApp/src/commonMain/kotlin/org/example/daybook/tables/Features.kt @@ -16,6 +16,17 @@ import androidx.navigation.NavHostController import org.example.daybook.AppScreens import org.example.daybook.capture.CaptureNavActions +fun routeForFeatureKey(featureKey: String): String? = + when (featureKey) { + FeatureKeys.Home -> AppScreens.Home.name + FeatureKeys.Capture -> AppScreens.Capture.name + FeatureKeys.Drawer -> AppScreens.Drawer.name + FeatureKeys.Tables -> AppScreens.Tables.name + FeatureKeys.Progress -> AppScreens.Progress.name + FeatureKeys.Settings -> AppScreens.Settings.name + else -> null + } + /** * All available features. This is the master list. */ diff --git a/src/daybook_compose/composeApp/src/commonMain/kotlin/org/example/daybook/welcome/welcome.kt b/src/daybook_compose/composeApp/src/commonMain/kotlin/org/example/daybook/welcome/welcome.kt index ad88192d..3c35cdb7 100644 --- a/src/daybook_compose/composeApp/src/commonMain/kotlin/org/example/daybook/welcome/welcome.kt +++ b/src/daybook_compose/composeApp/src/commonMain/kotlin/org/example/daybook/welcome/welcome.kt @@ -186,6 +186,15 @@ private object WelcomeRoute { const val CloneLocation = "welcome_clone_location" } +private suspend fun fetchDefaultParentDir(): Result = + try { + val defaultParent = withAppFfiCtx { gcx -> gcx.defaultCloneParentDir().trim() } + Result.success(defaultParent) + } catch (error: Throwable) { + if (error is CancellationException) throw error + Result.failure(error) + } + @Composable fun WelcomeFlowNavHost( repos: List, @@ -357,10 +366,9 @@ fun WelcomeFlowNavHost( } LaunchedEffect(Unit) { val repoName = "daybook-repo" - try { - val defaultParent = withAppFfiCtx { gcx -> - gcx.defaultCloneParentDir().trim() - } + val defaultParentResult = fetchDefaultParentDir() + if (defaultParentResult.isSuccess) { + val defaultParent = defaultParentResult.getOrThrow() onCreateRepoUiStateChange( CreateRepoUiState.Editing( repoName = repoName, @@ -368,8 +376,8 @@ fun WelcomeFlowNavHost( isCreating = false ) ) - } catch (error: Throwable) { - if (error is CancellationException) throw error + } else { + val error = defaultParentResult.exceptionOrNull() ?: error("unknown failure") onCreateRepoUiStateChange( CreateRepoUiState.Editing( repoName = repoName, @@ -419,10 +427,9 @@ fun WelcomeFlowNavHost( if (editState.parentPath.isBlank() && !editState.isCreating) { LaunchedEffect(editState.parentPath, editState.isCreating) { - try { - val defaultParent = withAppFfiCtx { gcx -> - gcx.defaultCloneParentDir().trim() - } + val defaultParentResult = fetchDefaultParentDir() + if (defaultParentResult.isSuccess) { + val defaultParent = defaultParentResult.getOrThrow() val latest = createRepoUiState as? CreateRepoUiState.Editing ?: return@LaunchedEffect if (latest.parentPath.isBlank()) { onCreateRepoUiStateChange( @@ -432,8 +439,8 @@ fun WelcomeFlowNavHost( ) ) } - } catch (error: Throwable) { - if (error is CancellationException) throw error + } else { + val error = defaultParentResult.exceptionOrNull() ?: error("unknown failure") updateCreateState { current -> current.copy( errorMessage = "Failed loading default parent: ${describeThrowable(error)}" diff --git a/src/daybook_compose/composeApp/src/desktopMain/kotlin/org/example/daybook/main.kt b/src/daybook_compose/composeApp/src/desktopMain/kotlin/org/example/daybook/main.kt index e7b73d9c..bf085e2c 100644 --- a/src/daybook_compose/composeApp/src/desktopMain/kotlin/org/example/daybook/main.kt +++ b/src/daybook_compose/composeApp/src/desktopMain/kotlin/org/example/daybook/main.kt @@ -51,6 +51,9 @@ fun main() = application { Runtime.getRuntime().addShutdownHook(hook) onDispose { runCatching { Runtime.getRuntime().removeShutdownHook(hook) } + .onFailure { error -> + println("[APP_SHUTDOWN] failed to remove shutdown hook err=${error.message}") + } } } From 4dc91865871941a9b93cd8528f8aa52fb1ebbef9 Mon Sep 17 00:00:00 2001 From: dman-os <61868957+dman-os@users.noreply.github.com> Date: Tue, 14 Apr 2026 11:08:07 +0300 Subject: [PATCH 11/16] fix: address feeedback --- .../daybook/DocEditorStoreViewModel.kt | 27 ++++++++++--------- .../daybook/capture/screens/CaptureScreen.kt | 2 -- .../daybook/tables/CenterNavBarContent.kt | 9 ++++--- .../org/example/daybook/welcome/welcome.kt | 2 +- 4 files changed, 21 insertions(+), 19 deletions(-) diff --git a/src/daybook_compose/composeApp/src/commonMain/kotlin/org/example/daybook/DocEditorStoreViewModel.kt b/src/daybook_compose/composeApp/src/commonMain/kotlin/org/example/daybook/DocEditorStoreViewModel.kt index 8da69c6c..5d700da9 100644 --- a/src/daybook_compose/composeApp/src/commonMain/kotlin/org/example/daybook/DocEditorStoreViewModel.kt +++ b/src/daybook_compose/composeApp/src/commonMain/kotlin/org/example/daybook/DocEditorStoreViewModel.kt @@ -12,6 +12,7 @@ import kotlinx.coroutines.isActive import kotlinx.coroutines.launch import kotlin.time.Clock import kotlin.time.Duration.Companion.minutes +import java.util.concurrent.ConcurrentHashMap import org.example.daybook.ui.editor.EditorSessionController import org.example.daybook.uniffi.DrawerEventListener import org.example.daybook.uniffi.DrawerRepoFfi @@ -27,7 +28,7 @@ private data class DocEditorSessionEntry( class DocEditorStoreViewModel( private val drawerRepo: DrawerRepoFfi ) : ViewModel() { - private val sessions = mutableMapOf() + private val sessions = ConcurrentHashMap() private val _selectedDocId = MutableStateFlow(null) val selectedDocId = _selectedDocId.asStateFlow() @@ -85,14 +86,14 @@ class DocEditorStoreViewModel( return } - val entry = sessions[docId] ?: createSession(docId) + val entry = createSession(docId) entry.lastTouchedMs = nowMs() _selectedController.value = entry.controller viewModelScope.launch { refreshDoc(docId) } } fun attachHost(docId: String) { - val entry = sessions[docId] ?: createSession(docId) + val entry = createSession(docId) entry.hostCount += 1 entry.lastTouchedMs = nowMs() } @@ -104,15 +105,15 @@ class DocEditorStoreViewModel( } private fun createSession(docId: String): DocEditorSessionEntry { - val controller = - EditorSessionController( - drawerRepo = drawerRepo, - scope = viewModelScope, - onDocCreated = { createdId -> selectDoc(createdId) } - ) - val entry = DocEditorSessionEntry(controller = controller) - sessions[docId] = entry - return entry + return sessions.computeIfAbsent(docId) { + val controller = + EditorSessionController( + drawerRepo = drawerRepo, + scope = viewModelScope, + onDocCreated = { createdId -> selectDoc(createdId) } + ) + DocEditorSessionEntry(controller = controller) + } } private suspend fun refreshDoc(docId: String) { @@ -126,7 +127,7 @@ class DocEditorStoreViewModel( val selectedId = _selectedDocId.value val now = nowMs() val toRemove = mutableListOf() - for ((docId, entry) in sessions) { + for ((docId, entry) in sessions.entries) { if (docId == selectedId) { continue } diff --git a/src/daybook_compose/composeApp/src/commonMain/kotlin/org/example/daybook/capture/screens/CaptureScreen.kt b/src/daybook_compose/composeApp/src/commonMain/kotlin/org/example/daybook/capture/screens/CaptureScreen.kt index 5e95078a..40d521fe 100644 --- a/src/daybook_compose/composeApp/src/commonMain/kotlin/org/example/daybook/capture/screens/CaptureScreen.kt +++ b/src/daybook_compose/composeApp/src/commonMain/kotlin/org/example/daybook/capture/screens/CaptureScreen.kt @@ -132,8 +132,6 @@ class CaptureScreenViewModel( "[CAPTURE] persistCaptureMode failed mode=$mode selectedTableId=$selectedTableId windowId=$windowId err=${e.message}" ) _message.tryEmit("Failed to persist capture mode") - } catch (t: Throwable) { - throw t } } } diff --git a/src/daybook_compose/composeApp/src/commonMain/kotlin/org/example/daybook/tables/CenterNavBarContent.kt b/src/daybook_compose/composeApp/src/commonMain/kotlin/org/example/daybook/tables/CenterNavBarContent.kt index e6124142..c9fac14d 100644 --- a/src/daybook_compose/composeApp/src/commonMain/kotlin/org/example/daybook/tables/CenterNavBarContent.kt +++ b/src/daybook_compose/composeApp/src/commonMain/kotlin/org/example/daybook/tables/CenterNavBarContent.kt @@ -13,7 +13,6 @@ import androidx.compose.animation.slideInHorizontally import androidx.compose.animation.slideOutHorizontally import androidx.compose.foundation.background import androidx.compose.foundation.border -import androidx.compose.foundation.clickable import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box @@ -39,6 +38,8 @@ import androidx.compose.ui.draw.clip import androidx.compose.ui.geometry.Rect import androidx.compose.ui.layout.boundsInWindow import androidx.compose.ui.layout.onGloballyPositioned +import androidx.compose.foundation.selection.selectable +import androidx.compose.ui.semantics.Role import androidx.compose.ui.unit.dp import androidx.navigation.NavHostController import androidx.navigation.compose.currentBackStackEntryAsState @@ -226,11 +227,13 @@ private fun CustomBottomBarItem( Box( modifier = modifier - .clickable( + .selectable( + selected = selected, + onClick = onClick, enabled = enabled, interactionSource = outerInteraction, indication = null, - onClick = onClick + role = Role.Tab ) .padding(vertical = 2.dp), contentAlignment = Alignment.Center diff --git a/src/daybook_compose/composeApp/src/commonMain/kotlin/org/example/daybook/welcome/welcome.kt b/src/daybook_compose/composeApp/src/commonMain/kotlin/org/example/daybook/welcome/welcome.kt index 3c35cdb7..2e045128 100644 --- a/src/daybook_compose/composeApp/src/commonMain/kotlin/org/example/daybook/welcome/welcome.kt +++ b/src/daybook_compose/composeApp/src/commonMain/kotlin/org/example/daybook/welcome/welcome.kt @@ -1089,7 +1089,7 @@ private fun CloneQrScannerScreen( val useNativePreviewQr = remember(cameraPreviewFfi) { cameraPreviewFfi.supportsNativeQrAnalysis() } var analyzer by remember { mutableStateOf(null) } LaunchedEffect(Unit) { - if (analyzer == null) { + if (analyzer == null && !useNativePreviewQr) { analyzer = withContext(Dispatchers.IO) { CameraQrAnalyzerFfi.load() } } } From 28f89c5086c4dae83e204b48878c1af9194bf42c Mon Sep 17 00:00:00 2001 From: dman-os <61868957+dman-os@users.noreply.github.com> Date: Tue, 14 Apr 2026 11:18:44 +0300 Subject: [PATCH 12/16] feat: drag on already open menu --- .../daybook/tables/FloatingBottomBar.kt | 65 ++++++++++--------- .../org/example/daybook/tables/compact.kt | 5 +- 2 files changed, 36 insertions(+), 34 deletions(-) diff --git a/src/daybook_compose/composeApp/src/commonMain/kotlin/org/example/daybook/tables/FloatingBottomBar.kt b/src/daybook_compose/composeApp/src/commonMain/kotlin/org/example/daybook/tables/FloatingBottomBar.kt index fccdfd6a..c6ff9091 100644 --- a/src/daybook_compose/composeApp/src/commonMain/kotlin/org/example/daybook/tables/FloatingBottomBar.kt +++ b/src/daybook_compose/composeApp/src/commonMain/kotlin/org/example/daybook/tables/FloatingBottomBar.kt @@ -202,40 +202,37 @@ fun FloatingGrowingMenuSheet( val openFraction = (sheetState.progress / maxAnchor).coerceIn(0f, 1f) val targetHeight = (maxMenuHeight * openFraction) val dragModifier = - if (enableDragToClose) { - Modifier.draggable( - state = - rememberDraggableState { dragAmount -> - val total = maxMenuHeightPx - val boundedProgress = sheetState.progress.coerceIn(0f, maxAnchor) - val currentVisible = total * (boundedProgress / maxAnchor).coerceIn(0f, 1f) - val nextVisible = (currentVisible - dragAmount).coerceIn(0f, total) - val nextProgress = ((nextVisible / total) * maxAnchor).coerceIn(0f, maxAnchor) - sheetState.setProgressImmediate(nextProgress) - }, - orientation = Orientation.Vertical, - onDragStopped = { velocityY -> - if (velocityY > flingCloseThreshold) { - scope.launch { - val anim = Animatable(sheetState.progress.coerceIn(0f, maxAnchor)) - anim.animateTo(0f, animationSpec = tween(durationMillis = 200)) { - sheetState.setProgressImmediate(value) - } - sheetState.hideInstant() - onDismiss() + Modifier.draggable( + state = + rememberDraggableState { dragAmount -> + val total = maxMenuHeightPx + val boundedProgress = sheetState.progress.coerceIn(0f, maxAnchor) + val currentVisible = total * (boundedProgress / maxAnchor).coerceIn(0f, 1f) + val nextVisible = (currentVisible - dragAmount).coerceIn(0f, total) + val nextProgress = ((nextVisible / total) * maxAnchor).coerceIn(0f, maxAnchor) + sheetState.setProgressImmediate(nextProgress) + }, + orientation = Orientation.Vertical, + onDragStopped = { velocityY -> + if (velocityY > flingCloseThreshold) { + scope.launch { + val anim = Animatable(sheetState.progress.coerceIn(0f, maxAnchor)) + anim.animateTo(0f, animationSpec = tween(durationMillis = 200)) { + sheetState.setProgressImmediate(value) } - } else { - sheetState.settle(velocityY) { settledProgress -> - if (settledProgress <= 0f) { - onDismiss() - } + sheetState.hideInstant() + onDismiss() + } + } else { + sheetState.settle(velocityY) { settledProgress -> + if (settledProgress <= 0f) { + onDismiss() } } } - ) - } else { - Modifier - } + } + ) + val barDragAreaHeight = barHeight + FloatingBarDefaults.verticalPadding * 2 val surfaceHeight = targetHeight.coerceAtLeast(1.dp) Surface( @@ -321,5 +318,13 @@ fun FloatingGrowingMenuSheet( } } } + Box( + modifier = + Modifier + .align(Alignment.BottomCenter) + .fillMaxWidth() + .height(barDragAreaHeight) + .then(dragModifier) + ) } } diff --git a/src/daybook_compose/composeApp/src/commonMain/kotlin/org/example/daybook/tables/compact.kt b/src/daybook_compose/composeApp/src/commonMain/kotlin/org/example/daybook/tables/compact.kt index 7d1732d9..1ac5a4c4 100644 --- a/src/daybook_compose/composeApp/src/commonMain/kotlin/org/example/daybook/tables/compact.kt +++ b/src/daybook_compose/composeApp/src/commonMain/kotlin/org/example/daybook/tables/compact.kt @@ -300,7 +300,7 @@ fun CompactLayout( onDragStart = { _ -> isDragging = true isLeftDrawerDragging = true - menuSheetOpenedByDrag = false + menuSheetOpenedByDrag = menuSheetState.isVisible horizontalDragDistance = 0f hoveredMenuItemKey = null activationReadyMenuItem = null @@ -308,9 +308,6 @@ fun CompactLayout( hoverActivationJob = null }, onDrag = { change, dragAmount -> - if (menuSheetState.isVisible && menuCloseDragEnabled) { - return@detectDragGestures - } horizontalDragDistance += dragAmount.x if (!menuSheetOpenedByDrag && dragAmount.y < 0f && kotlin.math.abs(dragAmount.y) > kotlin.math.abs(dragAmount.x)) { sheetContent = SheetContent.MENU From d58da14128157ee923af9f25aa3e2557df6773c0 Mon Sep 17 00:00:00 2001 From: dman-os <61868957+dman-os@users.noreply.github.com> Date: Tue, 14 Apr 2026 11:43:15 +0300 Subject: [PATCH 13/16] feat: cleanout --- .../daybook/tables/CenterNavBarContent.kt | 9 ++- .../org/example/daybook/tables/Features.kt | 10 --- .../org/example/daybook/tables/compact.kt | 37 +++-------- .../org/example/daybook/tables/expanded.kt | 18 +----- .../daybook/uniffi/core/daybook_core.kt | 7 +++ .../org/example/daybook/uniffi/daybook_ffi.kt | 63 +++++++++++++++++++ .../daybook/uniffi/types/daybook_types.kt | 1 + 7 files changed, 90 insertions(+), 55 deletions(-) diff --git a/src/daybook_compose/composeApp/src/commonMain/kotlin/org/example/daybook/tables/CenterNavBarContent.kt b/src/daybook_compose/composeApp/src/commonMain/kotlin/org/example/daybook/tables/CenterNavBarContent.kt index c9fac14d..a8c187c2 100644 --- a/src/daybook_compose/composeApp/src/commonMain/kotlin/org/example/daybook/tables/CenterNavBarContent.kt +++ b/src/daybook_compose/composeApp/src/commonMain/kotlin/org/example/daybook/tables/CenterNavBarContent.kt @@ -40,6 +40,7 @@ import androidx.compose.ui.layout.boundsInWindow import androidx.compose.ui.layout.onGloballyPositioned import androidx.compose.foundation.selection.selectable import androidx.compose.ui.semantics.Role +import androidx.compose.material3.LocalTextStyle import androidx.compose.ui.unit.dp import androidx.navigation.NavHostController import androidx.navigation.compose.currentBackStackEntryAsState @@ -122,7 +123,13 @@ fun RowScope.CenterNavBarContent( selectedFill = selectedFill, armedIndicatorColor = armedIndicatorColor, icon = button.icon, - label = button.label, + label = { + androidx.compose.runtime.CompositionLocalProvider( + LocalTextStyle provides MaterialTheme.typography.labelSmall + ) { + button.label() + } + }, onClick = { if (button.enabled) { scope.launch { button.onClick() } diff --git a/src/daybook_compose/composeApp/src/commonMain/kotlin/org/example/daybook/tables/Features.kt b/src/daybook_compose/composeApp/src/commonMain/kotlin/org/example/daybook/tables/Features.kt index d102304d..1bd1ee94 100644 --- a/src/daybook_compose/composeApp/src/commonMain/kotlin/org/example/daybook/tables/Features.kt +++ b/src/daybook_compose/composeApp/src/commonMain/kotlin/org/example/daybook/tables/Features.kt @@ -9,7 +9,6 @@ import androidx.compose.material.icons.filled.Home import androidx.compose.material.icons.filled.Notifications import androidx.compose.material.icons.filled.QrCode2 import androidx.compose.material.icons.filled.Settings -import androidx.compose.material.icons.filled.TableChart import androidx.compose.material3.Icon import androidx.compose.runtime.Composable import androidx.navigation.NavHostController @@ -21,7 +20,6 @@ fun routeForFeatureKey(featureKey: String): String? = FeatureKeys.Home -> AppScreens.Home.name FeatureKeys.Capture -> AppScreens.Capture.name FeatureKeys.Drawer -> AppScreens.Drawer.name - FeatureKeys.Tables -> AppScreens.Tables.name FeatureKeys.Progress -> AppScreens.Progress.name FeatureKeys.Settings -> AppScreens.Settings.name else -> null @@ -57,14 +55,6 @@ fun rememberAllFeatures(navController: NavHostController): List { onActivate = { navController.navigate(AppScreens.Drawer.name) }, onReselect = { navController.navigate(AppScreens.Drawer.name) } ), - FeatureItem( - key = FeatureKeys.Tables, - icon = { Icon(Icons.Default.TableChart, contentDescription = "Tables") }, - selectedIcon = { Icon(Icons.Default.TableChart, contentDescription = "Tables") }, - label = "Tables", - onActivate = { navController.navigate(AppScreens.Tables.name) }, - onReselect = { navController.navigate(AppScreens.Tables.name) } - ), FeatureItem( key = FeatureKeys.Progress, icon = { Icon(Icons.Default.Notifications, contentDescription = "Progress") }, diff --git a/src/daybook_compose/composeApp/src/commonMain/kotlin/org/example/daybook/tables/compact.kt b/src/daybook_compose/composeApp/src/commonMain/kotlin/org/example/daybook/tables/compact.kt index 1ac5a4c4..9dd08c99 100644 --- a/src/daybook_compose/composeApp/src/commonMain/kotlin/org/example/daybook/tables/compact.kt +++ b/src/daybook_compose/composeApp/src/commonMain/kotlin/org/example/daybook/tables/compact.kt @@ -924,7 +924,7 @@ fun LeftDrawer( modifier = modifier.width(320.dp), drawerContainerColor = MaterialTheme.colorScheme.surfaceContainer ) { - var selectedPane by remember { mutableIntStateOf(0) } + var selectedPane by remember { mutableIntStateOf(1) } Text( text = "LeftDrawer", @@ -934,7 +934,11 @@ fun LeftDrawer( HorizontalDivider() when (selectedPane) { - 0 -> { + 1 -> { + ProgressList(modifier = Modifier.weight(1f).fillMaxWidth()) + } + + else -> { selectedTable?.let { table -> Text( text = table.title, @@ -949,52 +953,31 @@ fun LeftDrawer( growUpward = false ) } - - else -> { - ProgressList(modifier = Modifier.weight(1f).fillMaxWidth()) - } } - TabRow(selectedTabIndex = selectedPane) { + TabRow(selectedTabIndex = 0) { Tab( - selected = selectedPane == 0, - onClick = { selectedPane = 0 }, - text = { Text("Tabs") } - ) - Tab( - selected = selectedPane == 1, + selected = true, onClick = { selectedPane = 1 }, text = { Text("Progress") } ) } - NavDrawerBottomBar(onAddTab = onAddTab, onClose = onDismiss) + NavDrawerBottomBar(onClose = onDismiss) } } @Composable fun NavDrawerBottomBar( - onAddTab: suspend () -> Unit, onClose: () -> Unit, modifier: Modifier = Modifier ) { - val scope = rememberCoroutineScope() BottomAppBar( modifier = modifier.fillMaxWidth(),//.height(70.dp), containerColor = MaterialTheme.colorScheme.surfaceContainerLow ) { - Button( - onClick = { - scope.launch { - onAddTab() - } - }, - modifier = Modifier.weight(1f).padding(start = 8.dp) - ) { - Text("Add Tab") - } OutlinedButton( onClick = onClose, - modifier = Modifier.padding(horizontal = 8.dp) + modifier = Modifier.fillMaxWidth().padding(horizontal = 8.dp) ) { Text("Close") } diff --git a/src/daybook_compose/composeApp/src/commonMain/kotlin/org/example/daybook/tables/expanded.kt b/src/daybook_compose/composeApp/src/commonMain/kotlin/org/example/daybook/tables/expanded.kt index c8503f6e..c11e713f 100644 --- a/src/daybook_compose/composeApp/src/commonMain/kotlin/org/example/daybook/tables/expanded.kt +++ b/src/daybook_compose/composeApp/src/commonMain/kotlin/org/example/daybook/tables/expanded.kt @@ -528,7 +528,6 @@ fun SidebarContent(navController: NavHostController, modifier: Modifier = Modifi // Map feature keys to routes for selection fun getRouteForFeature(feature: FeatureItem): String? = when (feature.key) { FeatureKeys.Home -> AppScreens.Home.name - FeatureKeys.Tables -> AppScreens.Tables.name FeatureKeys.Capture -> AppScreens.Capture.name FeatureKeys.Drawer -> AppScreens.Drawer.name FeatureKeys.Settings -> AppScreens.Settings.name @@ -542,7 +541,7 @@ fun SidebarContent(navController: NavHostController, modifier: Modifier = Modifi } ) { if (isWide) { - var selectedSidebarPane by remember { mutableIntStateOf(0) } + var selectedSidebarPane by remember { mutableIntStateOf(1) } // Wide mode: navigation row + tabbed pane (tabs/progress) PermanentDrawerSheet( @@ -591,13 +590,6 @@ fun SidebarContent(navController: NavHostController, modifier: Modifier = Modifi NavigationRail( modifier = Modifier.fillMaxHeight().padding(top = 8.dp) ) { - NavigationRailItem( - selected = selectedSidebarPane == 0, - onClick = { selectedSidebarPane = 0 }, - icon = { Icon(Icons.Default.Menu, contentDescription = "Tabs") }, - label = { Text("Tabs") }, - alwaysShowLabel = true - ) NavigationRailItem( selected = selectedSidebarPane == 1, onClick = { selectedSidebarPane = 1 }, @@ -619,7 +611,6 @@ fun SidebarContent(navController: NavHostController, modifier: Modifier = Modifi ) { val paneTitle = when (selectedSidebarPane) { - 0 -> "Tabs" 1 -> "Progress" else -> "Drawer" } @@ -634,13 +625,6 @@ fun SidebarContent(navController: NavHostController, modifier: Modifier = Modifi } HorizontalDivider() when (selectedSidebarPane) { - 0 -> { - TabSelectionList( - onTabSelected = { /* TODO: Handle tab selection */ }, - modifier = Modifier.weight(1f) - ) - } - 1 -> { ProgressList(modifier = Modifier.weight(1f).fillMaxWidth()) } diff --git a/src/daybook_compose/composeApp/src/commonMain/kotlin/org/example/daybook/uniffi/core/daybook_core.kt b/src/daybook_compose/composeApp/src/commonMain/kotlin/org/example/daybook/uniffi/core/daybook_core.kt index b3af8aa9..0e882c7d 100644 --- a/src/daybook_compose/composeApp/src/commonMain/kotlin/org/example/daybook/uniffi/core/daybook_core.kt +++ b/src/daybook_compose/composeApp/src/commonMain/kotlin/org/example/daybook/uniffi/core/daybook_core.kt @@ -5815,3 +5815,10 @@ public object FfiConverterTypeUuid: FfiConverter { */ public typealias VersionTag = kotlin.String public typealias FfiConverterTypeVersionTag = FfiConverterString + + + + + + + diff --git a/src/daybook_compose/composeApp/src/commonMain/kotlin/org/example/daybook/uniffi/daybook_ffi.kt b/src/daybook_compose/composeApp/src/commonMain/kotlin/org/example/daybook/uniffi/daybook_ffi.kt index 9a8ed980..6dd8c03a 100644 --- a/src/daybook_compose/composeApp/src/commonMain/kotlin/org/example/daybook/uniffi/daybook_ffi.kt +++ b/src/daybook_compose/composeApp/src/commonMain/kotlin/org/example/daybook/uniffi/daybook_ffi.kt @@ -11528,3 +11528,66 @@ public object FfiConverterTypeUuid: FfiConverter { FfiConverterByteArray.write(builtinValue, buf) } } + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/daybook_compose/composeApp/src/commonMain/kotlin/org/example/daybook/uniffi/types/daybook_types.kt b/src/daybook_compose/composeApp/src/commonMain/kotlin/org/example/daybook/uniffi/types/daybook_types.kt index e832dddd..6ce412db 100644 --- a/src/daybook_compose/composeApp/src/commonMain/kotlin/org/example/daybook/uniffi/types/daybook_types.kt +++ b/src/daybook_compose/composeApp/src/commonMain/kotlin/org/example/daybook/uniffi/types/daybook_types.kt @@ -3243,3 +3243,4 @@ public object FfiConverterTypeUuid: FfiConverter { FfiConverterByteArray.write(builtinValue, buf) } } + From 7d124ce8cc6f939046a7e4f543534fd7ea19b479 Mon Sep 17 00:00:00 2001 From: dman-os <61868957+dman-os@users.noreply.github.com> Date: Tue, 14 Apr 2026 12:00:06 +0300 Subject: [PATCH 14/16] fix: left rail sizing issues --- .../org/example/daybook/tables/expanded.kt | 22 +++++++++---------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/src/daybook_compose/composeApp/src/commonMain/kotlin/org/example/daybook/tables/expanded.kt b/src/daybook_compose/composeApp/src/commonMain/kotlin/org/example/daybook/tables/expanded.kt index c11e713f..a018348a 100644 --- a/src/daybook_compose/composeApp/src/commonMain/kotlin/org/example/daybook/tables/expanded.kt +++ b/src/daybook_compose/composeApp/src/commonMain/kotlin/org/example/daybook/tables/expanded.kt @@ -99,14 +99,14 @@ private object SidebarLayoutConstants { /** Default expanded sidebar weight (40% of available space) */ const val DEFAULT_SIDEBAR_WEIGHT = 0.4f - /** Collapsed/rail sidebar weight (10% of available space) */ - const val COLLAPSED_SIDEBAR_WEIGHT = 0.10f + /** Collapsed/rail sidebar weight (5% of available space) */ + const val COLLAPSED_SIDEBAR_WEIGHT = 0.04f /** Threshold weight to determine if sidebar is expanded or collapsed */ const val SIDEBAR_EXPANDED_THRESHOLD = 0.15f /** Minimum weight for any pane to prevent it from disappearing */ - const val MIN_PANE_WEIGHT = 0.10f + const val MIN_PANE_WEIGHT = 0.040f /** Rail mode size in dp (icon-only navigation rail) */ const val RAIL_SIZE_DP = 40f @@ -590,13 +590,6 @@ fun SidebarContent(navController: NavHostController, modifier: Modifier = Modifi NavigationRail( modifier = Modifier.fillMaxHeight().padding(top = 8.dp) ) { - NavigationRailItem( - selected = selectedSidebarPane == 1, - onClick = { selectedSidebarPane = 1 }, - icon = { Icon(Icons.Default.MoreVert, contentDescription = "Progress") }, - label = { Text("Progress") }, - alwaysShowLabel = true - ) NavigationRailItem( selected = selectedSidebarPane == 2, onClick = { selectedSidebarPane = 2 }, @@ -604,6 +597,13 @@ fun SidebarContent(navController: NavHostController, modifier: Modifier = Modifi label = { Text("Drawer") }, alwaysShowLabel = true ) + NavigationRailItem( + selected = selectedSidebarPane == 1, + onClick = { selectedSidebarPane = 1 }, + icon = { Icon(Icons.Default.MoreVert, contentDescription = "Progress") }, + label = { Text("Progress") }, + alwaysShowLabel = true + ) } VerticalDivider() Column( @@ -651,7 +651,7 @@ fun SidebarContent(navController: NavHostController, modifier: Modifier = Modifi } } else { // Narrow mode: NavigationRail with features only - NavigationRail(modifier = Modifier.fillMaxHeight()) { + NavigationRail(modifier = Modifier.align(Alignment.Center).fillMaxHeight()) { allSidebarFeatures.forEach { item -> val featureRoute = getRouteForFeature(item) val isDocEditorRoute = currentRoute == AppScreens.DocEditor.name From 47f1ecff58ee984eb7885eeafff89e1528294d9d Mon Sep 17 00:00:00 2001 From: dman-os <61868957+dman-os@users.noreply.github.com> Date: Tue, 14 Apr 2026 12:22:21 +0300 Subject: [PATCH 15/16] fix: sidebar bugs --- .../org/example/daybook/tables/expanded.kt | 323 ++++++++++++++++-- 1 file changed, 292 insertions(+), 31 deletions(-) diff --git a/src/daybook_compose/composeApp/src/commonMain/kotlin/org/example/daybook/tables/expanded.kt b/src/daybook_compose/composeApp/src/commonMain/kotlin/org/example/daybook/tables/expanded.kt index a018348a..2a80ccf1 100644 --- a/src/daybook_compose/composeApp/src/commonMain/kotlin/org/example/daybook/tables/expanded.kt +++ b/src/daybook_compose/composeApp/src/commonMain/kotlin/org/example/daybook/tables/expanded.kt @@ -99,17 +99,17 @@ private object SidebarLayoutConstants { /** Default expanded sidebar weight (40% of available space) */ const val DEFAULT_SIDEBAR_WEIGHT = 0.4f - /** Collapsed/rail sidebar weight (5% of available space) */ - const val COLLAPSED_SIDEBAR_WEIGHT = 0.04f + /** Collapsed/rail sidebar weight (10% of available space) */ + const val COLLAPSED_SIDEBAR_WEIGHT = 0.10f /** Threshold weight to determine if sidebar is expanded or collapsed */ const val SIDEBAR_EXPANDED_THRESHOLD = 0.15f /** Minimum weight for any pane to prevent it from disappearing */ - const val MIN_PANE_WEIGHT = 0.040f + const val MIN_PANE_WEIGHT = 0.10f - /** Rail mode size in dp (icon-only navigation rail) */ - const val RAIL_SIZE_DP = 40f + /** Rail mode transition point in dp (icon-only navigation rail vs drawer) */ + const val RAIL_TRANSITION_DP = 80f /** Maximum dp for discrete regime (transition point from rail to drawer) */ const val DISCRETE_REGIME_MAX_DP = 235f @@ -445,14 +445,14 @@ fun LayoutFromConfig( val leftPane = layoutConfig.leftRegion.deets val leftRegimes = if (leftPane.variant is WindowLayoutPaneVariant.Sidebar) { - // Sidebar: discrete 0-RAIL_SIZE_DP for rail mode, continuous above + // Sidebar: discrete 0-RAIL_TRANSITION_DP for rail mode, continuous above listOf( PaneSizeRegime.Discrete( minDp = 0f, maxDp = SidebarLayoutConstants.DISCRETE_REGIME_MAX_DP, - sizeDp = SidebarLayoutConstants.RAIL_SIZE_DP + sizeDp = SidebarLayoutConstants.RAIL_TRANSITION_DP ), - PaneSizeRegime.Continuous(minDp = SidebarLayoutConstants.RAIL_SIZE_DP) + PaneSizeRegime.Continuous(minDp = SidebarLayoutConstants.RAIL_TRANSITION_DP) ) } else { // Default: continuous @@ -651,7 +651,12 @@ fun SidebarContent(navController: NavHostController, modifier: Modifier = Modifi } } else { // Narrow mode: NavigationRail with features only - NavigationRail(modifier = Modifier.align(Alignment.Center).fillMaxHeight()) { + NavigationRail( + modifier = + Modifier + .align(Alignment.Center) + .fillMaxHeight() + ) { allSidebarFeatures.forEach { item -> val featureRoute = getRouteForFeature(item) val isDocEditorRoute = currentRoute == AppScreens.DocEditor.name @@ -756,14 +761,14 @@ fun RenderLayoutRegion( val childPane = child.deets val childRegimes = if (childPane.variant is WindowLayoutPaneVariant.Sidebar) { - // Sidebar: discrete 0-RAIL_SIZE_DP for rail mode, continuous above + // Sidebar: discrete 0-RAIL_TRANSITION_DP for rail mode, continuous above listOf( PaneSizeRegime.Discrete( minDp = 0f, maxDp = SidebarLayoutConstants.DISCRETE_REGIME_MAX_DP, - sizeDp = SidebarLayoutConstants.RAIL_SIZE_DP + sizeDp = SidebarLayoutConstants.RAIL_TRANSITION_DP ), - PaneSizeRegime.Continuous(minDp = SidebarLayoutConstants.RAIL_SIZE_DP) + PaneSizeRegime.Continuous(minDp = SidebarLayoutConstants.RAIL_TRANSITION_DP) ) } else { // Default: continuous @@ -873,6 +878,150 @@ fun DockableRegion( // Accessor to get weight for a specific key fun getWeight(key: Any): Float = weightMap[key] ?: 1f + private fun logDiscreteWeightConversion( + phase: String, + keyA: Any, + keyB: Any, + regime: PaneSizeRegime.Discrete, + virtualSizeDpA: Float, + virtualSizeDpB: Float, + totalSizeDp: Float, + totalWeight: Float, + totalCurrentWeight: Float, + targetWeightA: Float, + targetWeightB: Float, + newWeightA: Float, + newWeightB: Float + ) { + println( + buildString { + append("[DOCKABLE_DISCRETE] phase=") + append(phase) + append(" keyA=") + append(keyA) + append(" keyB=") + append(keyB) + append(" regime={minDp=") + append(regime.minDp) + append(", maxDp=") + append(regime.maxDp) + append(", sizeDp=") + append(regime.sizeDp) + append("}") + append(" virtualSizeDpA=") + append(virtualSizeDpA) + append(" virtualSizeDpB=") + append(virtualSizeDpB) + append(" totalSizeDp=") + append(totalSizeDp) + append(" totalWeight=") + append(totalWeight) + append(" totalCurrentWeight=") + append(totalCurrentWeight) + append(" targetWeightA=") + append(targetWeightA) + append(" targetWeightB=") + append(targetWeightB) + append(" newWeightA=") + append(newWeightA) + append(" newWeightB=") + append(newWeightB) + } + ) + } + + private fun sizeDpToWeight(sizeDp: Float, totalSizeDp: Float, totalWeight: Float): Float = + if (totalSizeDp <= 0f) 0f else (sizeDp / totalSizeDp) * totalWeight + + private fun resolveDiscreteTargetSizeDp( + regime: PaneSizeRegime.Discrete, + virtualSizeDp: Float + ): Float = + when { + virtualSizeDp < regime.minDp -> regime.sizeDp + virtualSizeDp <= regime.maxDp -> regime.sizeDp + else -> virtualSizeDp + } + + private fun rebalanceDiscreteWeights(keys: List, totalSizePx: Int) { + if (totalSizePx <= 0 || keys.isEmpty()) return + + val totalSizeDp = with(density) { totalSizePx.toDp().value } + if (totalSizeDp <= 0f) return + + val totalWeight = keys.sumOf { getWeight(it).toDouble() }.toFloat() + if (totalWeight <= 0f) return + + val currentWeights = keys.associateWith { getWeight(it) } + val discreteTargets = linkedMapOf>() + + keys.forEach { key -> + val currentSizeDp = getSizeDp(key, totalSizePx, totalWeight) + val regime = getCurrentRegime(key, currentSizeDp) + if (regime is PaneSizeRegime.Discrete) { + discreteTargets[key] = regime to sizeDpToWeight(regime.sizeDp, totalSizeDp, totalWeight) + } + } + + if (discreteTargets.isEmpty()) return + + val discreteTargetSum = + discreteTargets.values.sumOf { it.second.toDouble() }.toFloat() + val fixedScale = + if (discreteTargetSum > totalWeight && discreteTargetSum > 0f) { + totalWeight / discreteTargetSum + } else { + 1f + } + + val appliedDiscreteWeights = + discreteTargets.mapValues { (_, value) -> value.second * fixedScale } + val appliedDiscreteSum = appliedDiscreteWeights.values.sum() + + val remainingKeys = keys.filterNot { appliedDiscreteWeights.containsKey(it) } + val remainingBudget = (totalWeight - appliedDiscreteSum).coerceAtLeast(0f) + val remainingCurrentSum = + remainingKeys.sumOf { currentWeights.getValue(it).toDouble() }.toFloat() + + val redistributedRemainingWeights = + when { + remainingKeys.isEmpty() -> emptyMap() + remainingCurrentSum > 0f -> { + remainingKeys.associateWith { key -> + (currentWeights.getValue(key) / remainingCurrentSum) * remainingBudget + } + } + + else -> { + val fallbackWeight = remainingBudget / remainingKeys.size.toFloat() + remainingKeys.associateWith { fallbackWeight } + } + } + + (appliedDiscreteWeights.mapValues { it.value } + redistributedRemainingWeights).forEach { (key, weight) -> + weightMap[key] = weight + } + + val newTotalWeight = getTotalWeight() + keys.forEach { key -> + sizeDpMap[key] = getSizeDp(key, totalSizePx, newTotalWeight) + } + + discreteTargets.forEach { (key, value) -> + val regime = value.first + val currentWeight = currentWeights.getValue(key) + val appliedWeight = appliedDiscreteWeights.getValue(key) + println( + "[DOCKABLE_DISCRETE] phase=reconcile/resize key=$key " + + "regime={minDp=${regime.minDp}, maxDp=${regime.maxDp}, sizeDp=${regime.sizeDp}} " + + "totalSizeDp=$totalSizeDp totalWeight=$totalWeight currentWeight=$currentWeight " + + "targetWeight=${value.second} appliedWeight=$appliedWeight fixedScale=$fixedScale " + + "appliedDiscreteSum=$appliedDiscreteSum remainingBudget=$remainingBudget " + + "remainingCurrentSum=$remainingCurrentSum newTotalWeight=$newTotalWeight" + ) + } + } + // Get current size in dp for a key private fun getSizeDp(key: Any, totalSizePx: Int, totalWeight: Float): Float { if (totalSizePx == 0 || totalWeight == 0f) return sizeDpMap[key] ?: 0f @@ -884,12 +1033,16 @@ fun DockableRegion( // Determine which regime a pane is currently in based on its size private fun getCurrentRegime(key: Any, sizeDp: Float): PaneSizeRegime? { val regimes = paneRegimes[key] ?: return null + val firstRegime = regimes.firstOrNull() + if (sizeDp < 0f && firstRegime is PaneSizeRegime.Discrete) { + return firstRegime + } return regimes.find { regime -> when (regime) { is PaneSizeRegime.Discrete -> sizeDp >= regime.minDp && sizeDp <= regime.maxDp is PaneSizeRegime.Continuous -> sizeDp >= regime.minDp && sizeDp <= regime.maxDp } - } ?: regimes.lastOrNull() + } } // The Logic: Syncs the incoming N items with our storage @@ -911,6 +1064,15 @@ fun DockableRegion( sizeIterator.remove() } } + + val weightIterator = weightMap.iterator() + while (weightIterator.hasNext()) { + if (!currentKeySet.contains(weightIterator.next().key)) { + weightIterator.remove() + } + } + + rebalanceDiscreteWeights(keys, totalSizePx) } // Start drag - track initial state of both panes @@ -960,29 +1122,62 @@ fun DockableRegion( if (inRangeA && inRangeB) { // Both in discrete ranges - larger one wins if (sizeA >= sizeB) { - val targetWeightA = (sizeA / totalSizeDp) * totalWeight + val targetWeightA = sizeDpToWeight(sizeA, totalSizeDp, totalWeight) + val targetWeightB = sizeDpToWeight(sizeB, totalSizeDp, totalWeight) val newWeightA = targetWeightA.coerceAtLeast( SidebarLayoutConstants.MIN_PANE_WEIGHT ) val newWeightB = (totalCurrentWeight - newWeightA).coerceAtLeast( SidebarLayoutConstants.MIN_PANE_WEIGHT ) + logDiscreteWeightConversion( + phase = "endDrag/bothDiscrete/AWins", + keyA = keyA, + keyB = keyB, + regime = regimeA, + virtualSizeDpA = virtualSizeDpA, + virtualSizeDpB = virtualSizeDpB, + totalSizeDp = totalSizeDp, + totalWeight = totalWeight, + totalCurrentWeight = totalCurrentWeight, + targetWeightA = targetWeightA, + targetWeightB = targetWeightB, + newWeightA = newWeightA, + newWeightB = newWeightB + ) weightMap[keyA] = newWeightA weightMap[keyB] = newWeightB } else { - val targetWeightB = (sizeB / totalSizeDp) * totalWeight + val targetWeightA = sizeDpToWeight(sizeA, totalSizeDp, totalWeight) + val targetWeightB = sizeDpToWeight(sizeB, totalSizeDp, totalWeight) val newWeightB = targetWeightB.coerceAtLeast( SidebarLayoutConstants.MIN_PANE_WEIGHT ) val newWeightA = (totalCurrentWeight - newWeightB).coerceAtLeast( SidebarLayoutConstants.MIN_PANE_WEIGHT ) + logDiscreteWeightConversion( + phase = "endDrag/bothDiscrete/BWins", + keyA = keyA, + keyB = keyB, + regime = regimeB, + virtualSizeDpA = virtualSizeDpA, + virtualSizeDpB = virtualSizeDpB, + totalSizeDp = totalSizeDp, + totalWeight = totalWeight, + totalCurrentWeight = totalCurrentWeight, + targetWeightA = targetWeightA, + targetWeightB = targetWeightB, + newWeightA = newWeightA, + newWeightB = newWeightB + ) weightMap[keyA] = newWeightA weightMap[keyB] = newWeightB } } else { // One or both outside discrete range - use virtual sizes - val targetWeightA = (virtualSizeDpA / totalSizeDp) * totalWeight + val targetSizeDpA = resolveDiscreteTargetSizeDp(regimeA, virtualSizeDpA) + val targetWeightA = sizeDpToWeight(targetSizeDpA, totalSizeDp, totalWeight) val newWeightA = targetWeightA .coerceAtLeast( @@ -1003,18 +1198,35 @@ fun DockableRegion( val inRangeA = virtualSizeDpA >= regimeA.minDp && virtualSizeDpA <= regimeA.maxDp if (inRangeA) { - val targetWeightA = (regimeA.sizeDp / totalSizeDp) * totalWeight + val targetWeightA = sizeDpToWeight(regimeA.sizeDp, totalSizeDp, totalWeight) + val targetWeightB = sizeDpToWeight(virtualSizeDpB, totalSizeDp, totalWeight) val newWeightA = targetWeightA.coerceAtLeast( SidebarLayoutConstants.MIN_PANE_WEIGHT ) val newWeightB = (totalCurrentWeight - newWeightA).coerceAtLeast( SidebarLayoutConstants.MIN_PANE_WEIGHT ) + logDiscreteWeightConversion( + phase = "endDrag/discreteA", + keyA = keyA, + keyB = keyB, + regime = regimeA, + virtualSizeDpA = virtualSizeDpA, + virtualSizeDpB = virtualSizeDpB, + totalSizeDp = totalSizeDp, + totalWeight = totalWeight, + totalCurrentWeight = totalCurrentWeight, + targetWeightA = targetWeightA, + targetWeightB = targetWeightB, + newWeightA = newWeightA, + newWeightB = newWeightB + ) weightMap[keyA] = newWeightA weightMap[keyB] = newWeightB } else { // Outside discrete range - use virtual size - val targetWeightA = (virtualSizeDpA / totalSizeDp) * totalWeight + val targetSizeDpA = resolveDiscreteTargetSizeDp(regimeA, virtualSizeDpA) + val targetWeightA = sizeDpToWeight(targetSizeDpA, totalSizeDp, totalWeight) val newWeightA = targetWeightA .coerceAtLeast( @@ -1035,18 +1247,35 @@ fun DockableRegion( val inRangeB = virtualSizeDpB >= regimeB.minDp && virtualSizeDpB <= regimeB.maxDp if (inRangeB) { - val targetWeightB = (regimeB.sizeDp / totalSizeDp) * totalWeight + val targetWeightA = sizeDpToWeight(virtualSizeDpA, totalSizeDp, totalWeight) + val targetWeightB = sizeDpToWeight(regimeB.sizeDp, totalSizeDp, totalWeight) val newWeightB = targetWeightB.coerceAtLeast( SidebarLayoutConstants.MIN_PANE_WEIGHT ) val newWeightA = (totalCurrentWeight - newWeightB).coerceAtLeast( SidebarLayoutConstants.MIN_PANE_WEIGHT ) + logDiscreteWeightConversion( + phase = "endDrag/discreteB", + keyA = keyA, + keyB = keyB, + regime = regimeB, + virtualSizeDpA = virtualSizeDpA, + virtualSizeDpB = virtualSizeDpB, + totalSizeDp = totalSizeDp, + totalWeight = totalWeight, + totalCurrentWeight = totalCurrentWeight, + targetWeightA = targetWeightA, + targetWeightB = targetWeightB, + newWeightA = newWeightA, + newWeightB = newWeightB + ) weightMap[keyA] = newWeightA weightMap[keyB] = newWeightB } else { // Outside discrete range - use virtual size - val targetWeightA = (virtualSizeDpA / totalSizeDp) * totalWeight + val targetSizeDpA = resolveDiscreteTargetSizeDp(regimeB, virtualSizeDpA) + val targetWeightA = sizeDpToWeight(targetSizeDpA, totalSizeDp, totalWeight) val newWeightA = targetWeightA .coerceAtLeast( @@ -1064,7 +1293,7 @@ fun DockableRegion( // Both continuous: use virtual sizes else -> { - val targetWeightA = (virtualSizeDpA / totalSizeDp) * totalWeight + val targetWeightA = sizeDpToWeight(virtualSizeDpA, totalSizeDp, totalWeight) val newWeightA = targetWeightA .coerceAtLeast( @@ -1133,8 +1362,8 @@ fun DockableRegion( if (virtualSizeDpA >= regimeA.minDp && virtualSizeDpA <= regimeA.maxDp) { regimeA.sizeDp } else { - // Outside discrete range, use virtual size (will cross to another regime) - virtualSizeDpA + // Outside discrete range, clamp below the rail and allow expansion above it + resolveDiscreteTargetSizeDp(regimeA, virtualSizeDpA) } } @@ -1154,8 +1383,8 @@ fun DockableRegion( if (virtualSizeDpB >= regimeB.minDp && virtualSizeDpB <= regimeB.maxDp) { regimeB.sizeDp } else { - // Outside discrete range, use virtual size (will cross to another regime) - virtualSizeDpB + // Outside discrete range, clamp below the rail and allow expansion above it + resolveDiscreteTargetSizeDp(regimeB, virtualSizeDpB) } } @@ -1180,8 +1409,9 @@ fun DockableRegion( if (sizeA >= sizeB) { // A wins val totalWeight = getTotalWeight() - val targetWeightA = - (sizeA / with(density) { totalSizePx.toDp().value }) * totalWeight + val totalSizeDp = with(density) { totalSizePx.toDp().value } + val targetWeightA = sizeDpToWeight(sizeA, totalSizeDp, totalWeight) + val targetWeightB = sizeDpToWeight(sizeB, totalSizeDp, totalWeight) val weightA = getWeight(keyA) val weightB = getWeight(keyB) val totalCurrentWeight = weightA + weightB @@ -1191,14 +1421,30 @@ fun DockableRegion( val newWeightB = (totalCurrentWeight - newWeightA).coerceAtLeast( SidebarLayoutConstants.MIN_PANE_WEIGHT ) + logDiscreteWeightConversion( + phase = "resize/bothDiscrete/AWins", + keyA = keyA, + keyB = keyB, + regime = regimeA, + virtualSizeDpA = virtualSizeDpA, + virtualSizeDpB = virtualSizeDpB, + totalSizeDp = totalSizeDp, + totalWeight = totalWeight, + totalCurrentWeight = totalCurrentWeight, + targetWeightA = targetWeightA, + targetWeightB = targetWeightB, + newWeightA = newWeightA, + newWeightB = newWeightB + ) weightMap[keyA] = newWeightA weightMap[keyB] = newWeightB return true } else { // B wins val totalWeight = getTotalWeight() - val targetWeightB = - (sizeB / with(density) { totalSizePx.toDp().value }) * totalWeight + val totalSizeDp = with(density) { totalSizePx.toDp().value } + val targetWeightA = sizeDpToWeight(sizeA, totalSizeDp, totalWeight) + val targetWeightB = sizeDpToWeight(sizeB, totalSizeDp, totalWeight) val weightA = getWeight(keyA) val weightB = getWeight(keyB) val totalCurrentWeight = weightA + weightB @@ -1208,6 +1454,21 @@ fun DockableRegion( val newWeightA = (totalCurrentWeight - newWeightB).coerceAtLeast( SidebarLayoutConstants.MIN_PANE_WEIGHT ) + logDiscreteWeightConversion( + phase = "resize/bothDiscrete/BWins", + keyA = keyA, + keyB = keyB, + regime = regimeB, + virtualSizeDpA = virtualSizeDpA, + virtualSizeDpB = virtualSizeDpB, + totalSizeDp = totalSizeDp, + totalWeight = totalWeight, + totalCurrentWeight = totalCurrentWeight, + targetWeightA = targetWeightA, + targetWeightB = targetWeightB, + newWeightA = newWeightA, + newWeightB = newWeightB + ) weightMap[keyA] = newWeightA weightMap[keyB] = newWeightB return true @@ -1218,8 +1479,8 @@ fun DockableRegion( // Convert target sizes to weights and apply val totalWeight = getTotalWeight() val totalSizeDp = with(density) { totalSizePx.toDp().value } - val targetWeightA = (targetSizeDpA / totalSizeDp) * totalWeight - val targetWeightB = (targetSizeDpB / totalSizeDp) * totalWeight + val targetWeightA = sizeDpToWeight(targetSizeDpA, totalSizeDp, totalWeight) + val targetWeightB = sizeDpToWeight(targetSizeDpB, totalSizeDp, totalWeight) // Ensure weights don't invert val weightA = getWeight(keyA) From 3f9f797006e0971ccd32b159e2e334c44e2aac6e Mon Sep 17 00:00:00 2001 From: dman-os <61868957+dman-os@users.noreply.github.com> Date: Tue, 14 Apr 2026 13:20:28 +0300 Subject: [PATCH 16/16] wip: wip --- .../kotlin/org/example/daybook/App.kt | 17 +- .../daybook/dockable/DockableDivider.kt | 122 +++ .../daybook/tables/SidebarMenuButton.kt | 44 + .../org/example/daybook/tables/compact.kt | 16 +- .../org/example/daybook/tables/expanded.kt | 755 +++++++----------- .../daybook/uniffi/core/daybook_core.kt | 7 - .../org/example/daybook/uniffi/daybook_ffi.kt | 63 -- .../daybook/uniffi/types/daybook_types.kt | 1 - .../kotlin/org/example/daybook/main.kt | 5 +- 9 files changed, 493 insertions(+), 537 deletions(-) create mode 100644 src/daybook_compose/composeApp/src/commonMain/kotlin/org/example/daybook/dockable/DockableDivider.kt create mode 100644 src/daybook_compose/composeApp/src/commonMain/kotlin/org/example/daybook/tables/SidebarMenuButton.kt diff --git a/src/daybook_compose/composeApp/src/commonMain/kotlin/org/example/daybook/App.kt b/src/daybook_compose/composeApp/src/commonMain/kotlin/org/example/daybook/App.kt index 6fc0ea5e..c4b6c277 100644 --- a/src/daybook_compose/composeApp/src/commonMain/kotlin/org/example/daybook/App.kt +++ b/src/daybook_compose/composeApp/src/commonMain/kotlin/org/example/daybook/App.kt @@ -155,6 +155,7 @@ enum class DaybookContentType { } val LocalPermCtx = compositionLocalOf { null } +val LocalAppExitRequest = compositionLocalOf<(() -> Unit)?> { null } data class PermissionsContext( val hasCamera: Boolean = false, @@ -706,7 +707,8 @@ fun App( navController: NavHostController = rememberNavController(), shutdownRequested: Boolean = false, onShutdownCompleted: (() -> Unit)? = null, - autoShutdownOnDispose: Boolean = true + autoShutdownOnDispose: Boolean = true, + onExitRequest: (() -> Unit)? = null ) { val permCtx = LocalPermCtx.current val appStartMark = remember { TimeSource.Monotonic.markNow() } @@ -1090,12 +1092,13 @@ fun App( // Provide camera capture context for coordination between camera and bottom bar val cameraCaptureContext = remember { CameraCaptureContext() } val chromeStateManager = remember { ChromeStateManager() } - ProvideCameraCaptureContext(cameraCaptureContext) { - CompositionLocalProvider( - LocalChromeStateManager provides chromeStateManager - ) { - val bigDialogState = remember { BigDialogState() } - AdaptiveAppLayout( + ProvideCameraCaptureContext(cameraCaptureContext) { + CompositionLocalProvider( + LocalChromeStateManager provides chromeStateManager, + LocalAppExitRequest provides onExitRequest + ) { + val bigDialogState = remember { BigDialogState() } + AdaptiveAppLayout( modifier = surfaceModifier, navController = navController, extraAction = extraAction, diff --git a/src/daybook_compose/composeApp/src/commonMain/kotlin/org/example/daybook/dockable/DockableDivider.kt b/src/daybook_compose/composeApp/src/commonMain/kotlin/org/example/daybook/dockable/DockableDivider.kt new file mode 100644 index 00000000..aefecc31 --- /dev/null +++ b/src/daybook_compose/composeApp/src/commonMain/kotlin/org/example/daybook/dockable/DockableDivider.kt @@ -0,0 +1,122 @@ +package org.example.daybook.dockable + +import androidx.compose.foundation.background +import androidx.compose.foundation.gestures.Orientation +import androidx.compose.foundation.gestures.detectHorizontalDragGestures +import androidx.compose.foundation.gestures.detectVerticalDragGestures +import androidx.compose.foundation.hoverable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.interaction.collectIsHoveredAsState +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.input.pointer.PointerIcon +import androidx.compose.ui.input.pointer.pointerHoverIcon +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.unit.dp + +@Composable +fun DockableDivider( + orientation: Orientation, + onDragStart: () -> Unit = {}, + onDrag: (Float) -> Unit, + onDragEnd: () -> Unit, + onDragCancel: () -> Unit, + modifier: Modifier = Modifier, + centerContent: (@Composable () -> Unit)? = null +) { + val interactionSource = remember { MutableInteractionSource() } + val isHovered by interactionSource.collectIsHoveredAsState() + val glowColor = + if (isHovered) { + MaterialTheme.colorScheme.primary.copy(alpha = 0.18f) + } else { + MaterialTheme.colorScheme.outline.copy(alpha = 0.06f) + } + val lineColor = + if (isHovered) { + MaterialTheme.colorScheme.primary.copy(alpha = 0.70f) + } else { + MaterialTheme.colorScheme.outline.copy(alpha = 0.28f) + } + val outerModifier = + modifier + .then( + when (orientation) { + Orientation.Horizontal -> Modifier.width(8.dp).fillMaxHeight() + Orientation.Vertical -> Modifier.height(8.dp).fillMaxWidth() + } + ) + .pointerHoverIcon(PointerIcon.Hand) + .background(glowColor) + .hoverable(interactionSource) + .pointerInput(Unit) { + when (orientation) { + Orientation.Horizontal -> { + detectHorizontalDragGestures( + onDragStart = { onDragStart() }, + onHorizontalDrag = { change, dragAmount -> + change.consume() + onDrag(dragAmount) + }, + onDragEnd = onDragEnd, + onDragCancel = onDragCancel + ) + } + + Orientation.Vertical -> { + detectVerticalDragGestures( + onDragStart = { onDragStart() }, + onVerticalDrag = { change, dragAmount -> + change.consume() + onDrag(dragAmount) + }, + onDragEnd = onDragEnd, + onDragCancel = onDragCancel + ) + } + } + } + + BoxWithConstraints( + modifier = outerModifier, + contentAlignment = Alignment.Center + ) { + val lineLength = + when (orientation) { + Orientation.Horizontal -> maxOf(18.dp, maxHeight * 0.25f) + Orientation.Vertical -> maxOf(18.dp, maxWidth * 0.25f) + } + val lineThickness = 8.dp + + Box( + modifier = + when (orientation) { + Orientation.Horizontal -> + Modifier.width(lineThickness).height(lineLength) + + Orientation.Vertical -> + Modifier.width(lineLength).height(lineThickness) + }.background( + color = lineColor, + shape = RoundedCornerShape(4.dp) + ) + ) + if (centerContent != null) { + Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + centerContent() + } + } + } +} diff --git a/src/daybook_compose/composeApp/src/commonMain/kotlin/org/example/daybook/tables/SidebarMenuButton.kt b/src/daybook_compose/composeApp/src/commonMain/kotlin/org/example/daybook/tables/SidebarMenuButton.kt new file mode 100644 index 00000000..a0bf0adb --- /dev/null +++ b/src/daybook_compose/composeApp/src/commonMain/kotlin/org/example/daybook/tables/SidebarMenuButton.kt @@ -0,0 +1,44 @@ +package org.example.daybook.tables + +import androidx.compose.foundation.layout.Box +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Close +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +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.setValue +import androidx.compose.ui.Modifier +import androidx.compose.material3.MaterialTheme +import org.example.daybook.LocalAppExitRequest + +@Composable +fun SidebarMenuButton(modifier: Modifier = Modifier) { + val exitRequest = LocalAppExitRequest.current + var showMenu by remember { mutableStateOf(false) } + + Box(modifier = modifier) { + IconButton(onClick = { showMenu = true }) { + Text("🌞", style = MaterialTheme.typography.titleMedium) + } + DropdownMenu( + expanded = showMenu, + onDismissRequest = { showMenu = false } + ) { + DropdownMenuItem( + text = { Text("Exit") }, + leadingIcon = { Icon(Icons.Default.Close, contentDescription = null) }, + enabled = exitRequest != null, + onClick = { + showMenu = false + exitRequest?.invoke() + } + ) + } + } +} diff --git a/src/daybook_compose/composeApp/src/commonMain/kotlin/org/example/daybook/tables/compact.kt b/src/daybook_compose/composeApp/src/commonMain/kotlin/org/example/daybook/tables/compact.kt index 9dd08c99..106ebc13 100644 --- a/src/daybook_compose/composeApp/src/commonMain/kotlin/org/example/daybook/tables/compact.kt +++ b/src/daybook_compose/composeApp/src/commonMain/kotlin/org/example/daybook/tables/compact.kt @@ -926,11 +926,17 @@ fun LeftDrawer( ) { var selectedPane by remember { mutableIntStateOf(1) } - Text( - text = "LeftDrawer", - style = MaterialTheme.typography.titleMedium, - modifier = Modifier.padding(16.dp) - ) + Row( + modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 12.dp), + verticalAlignment = Alignment.CenterVertically + ) { + SidebarMenuButton() + Spacer(Modifier.width(12.dp)) + Text( + text = "Daybook", + style = MaterialTheme.typography.titleMedium + ) + } HorizontalDivider() when (selectedPane) { diff --git a/src/daybook_compose/composeApp/src/commonMain/kotlin/org/example/daybook/tables/expanded.kt b/src/daybook_compose/composeApp/src/commonMain/kotlin/org/example/daybook/tables/expanded.kt index 2a80ccf1..4ac33638 100644 --- a/src/daybook_compose/composeApp/src/commonMain/kotlin/org/example/daybook/tables/expanded.kt +++ b/src/daybook_compose/composeApp/src/commonMain/kotlin/org/example/daybook/tables/expanded.kt @@ -3,11 +3,6 @@ package org.example.daybook.tables import androidx.compose.foundation.gestures.Orientation -import androidx.compose.foundation.gestures.detectHorizontalDragGestures -import androidx.compose.foundation.gestures.detectVerticalDragGestures -import androidx.compose.foundation.hoverable -import androidx.compose.foundation.interaction.MutableInteractionSource -import androidx.compose.foundation.interaction.collectIsHoveredAsState import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box @@ -59,10 +54,6 @@ import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.input.pointer.PointerIcon -import androidx.compose.ui.input.pointer.PointerInputChange -import androidx.compose.ui.input.pointer.pointerHoverIcon -import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.layout.onSizeChanged import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.unit.dp @@ -76,6 +67,7 @@ import org.example.daybook.ChromeStateTopAppBar import org.example.daybook.ConfigViewModel import org.example.daybook.DaybookContentType import org.example.daybook.DrawerViewModel +import org.example.daybook.dockable.DockableDivider import org.example.daybook.LocalChromeStateManager import org.example.daybook.LocalContainer import org.example.daybook.LocalDocEditorStore @@ -91,6 +83,8 @@ import org.example.daybook.uniffi.core.WindowLayoutPane import org.example.daybook.uniffi.core.WindowLayoutPaneVariant import org.example.daybook.uniffi.core.WindowLayoutRegion import org.example.daybook.uniffi.core.WindowLayoutRegionSize +import org.example.daybook.uniffi.core.Window +import org.example.daybook.uniffi.core.Uuid /** * Constants for sidebar layout weights and sizes @@ -331,9 +325,6 @@ fun ExpandedLayout( Icon(Icons.Filled.Add, "Large floating action button") } } - }, - topBar = { - ChromeStateTopAppBar(mergedChromeState) } ) { innerPadding -> if (layoutConfig != null) { @@ -342,6 +333,7 @@ fun ExpandedLayout( tablesVm = tablesVm, navController = navController, extraAction = extraAction, + chromeState = mergedChromeState, modifier = Modifier.padding(innerPadding).fillMaxSize(), contentType = contentType ) @@ -360,6 +352,7 @@ fun LayoutFromConfig( tablesVm: TablesViewModel, navController: NavHostController, extraAction: (() -> Unit)?, + chromeState: ChromeState, modifier: Modifier = Modifier, contentType: DaybookContentType ) { @@ -368,55 +361,41 @@ fun LayoutFromConfig( val selectedTableId by tablesVm.selectedTableId.collectAsState() fun updateWeights(newWeights: Map) { - if (tablesState is TablesState.Data && selectedTableId != null) { - val state = tablesState as TablesState.Data - val windowId = - state.tables[selectedTableId]?.window?.let { windowPolicy -> - when (windowPolicy) { - is org.example.daybook.uniffi.core.TableWindow.Specific -> windowPolicy.id - is org.example.daybook.uniffi.core.TableWindow.AllWindows -> state.windows.keys.firstOrNull() - } - } - - windowId?.let { id -> - val window = state.windows[id] - if (window != null) { - val currentLayout = window.layout - val newLayout = - currentLayout.copy( - leftRegion = - currentLayout.leftRegion.copy( - size = - WindowLayoutRegionSize.Weight( - newWeights[currentLayout.leftRegion.deets.key] - ?: (currentLayout.leftRegion.size as? WindowLayoutRegionSize.Weight)?.v1 - ?: SidebarLayoutConstants.DEFAULT_SIDEBAR_WEIGHT - ) - ), - centerRegion = - currentLayout.centerRegion.copy( - size = - WindowLayoutRegionSize.Weight( - newWeights[currentLayout.centerRegion.deets.key] - ?: (currentLayout.centerRegion.size as? WindowLayoutRegionSize.Weight)?.v1 - ?: 1.0f - ) - ), - rightRegion = - currentLayout.rightRegion.copy( - size = - WindowLayoutRegionSize.Weight( - newWeights[currentLayout.rightRegion.deets.key] - ?: (currentLayout.rightRegion.size as? WindowLayoutRegionSize.Weight)?.v1 - ?: SidebarLayoutConstants.DEFAULT_SIDEBAR_WEIGHT - ) - ) - ) - scope.launch { - tablesVm.tablesRepo.setWindow(id, window.copy(layout = newLayout)) - } - } - } + val selectedWindow = resolveSelectedWindow(tablesState, selectedTableId) ?: return + val (windowId, window) = selectedWindow + val currentLayout = window.layout + val newLayout = + currentLayout.copy( + leftRegion = + currentLayout.leftRegion.copy( + size = + WindowLayoutRegionSize.Weight( + newWeights[currentLayout.leftRegion.deets.key] + ?: (currentLayout.leftRegion.size as? WindowLayoutRegionSize.Weight)?.v1 + ?: SidebarLayoutConstants.DEFAULT_SIDEBAR_WEIGHT + ) + ), + centerRegion = + currentLayout.centerRegion.copy( + size = + WindowLayoutRegionSize.Weight( + newWeights[currentLayout.centerRegion.deets.key] + ?: (currentLayout.centerRegion.size as? WindowLayoutRegionSize.Weight)?.v1 + ?: 1.0f + ) + ), + rightRegion = + currentLayout.rightRegion.copy( + size = + WindowLayoutRegionSize.Weight( + newWeights[currentLayout.rightRegion.deets.key] + ?: (currentLayout.rightRegion.size as? WindowLayoutRegionSize.Weight)?.v1 + ?: SidebarLayoutConstants.DEFAULT_SIDEBAR_WEIGHT + ) + ) + ) + scope.launch { + tablesVm.tablesRepo.setWindow(windowId, window.copy(layout = newLayout)) } } @@ -458,7 +437,18 @@ fun LayoutFromConfig( // Default: continuous listOf(PaneSizeRegime.Continuous()) } - pane(key = leftPane.key, regimes = leftRegimes) { + pane( + key = leftPane.key, + regimes = leftRegimes, + dividerContent = + { + SidebarPaneDividerToggleButton( + tablesVm = tablesVm, + tablesState = tablesState, + selectedTableId = selectedTableId + ) + } + ) { RenderLayoutPane( pane = leftPane, navController = navController, @@ -471,13 +461,16 @@ fun LayoutFromConfig( // Center region (always visible) pane(key = layoutConfig.centerRegion.deets.key) { - RenderLayoutPane( - pane = layoutConfig.centerRegion.deets, - navController = navController, - extraAction = extraAction, - modifier = Modifier.fillMaxSize(), - contentType = contentType - ) + Column(modifier = Modifier.fillMaxSize()) { + ChromeStateTopAppBar(chromeState) + RenderLayoutPane( + pane = layoutConfig.centerRegion.deets, + navController = navController, + extraAction = extraAction, + modifier = Modifier.weight(1f).fillMaxWidth(), + contentType = contentType + ) + } } // Right region (if visible) @@ -495,6 +488,78 @@ fun LayoutFromConfig( } } +private fun resolveSelectedWindow( + tablesState: TablesState, + selectedTableId: Uuid? +): Pair? { + if (tablesState !is TablesState.Data || selectedTableId == null) return null + val windowId = + tablesState.tables[selectedTableId]?.window?.let { windowPolicy -> + when (windowPolicy) { + is org.example.daybook.uniffi.core.TableWindow.Specific -> windowPolicy.id + is org.example.daybook.uniffi.core.TableWindow.AllWindows -> tablesState.windows.keys.firstOrNull() + } + } ?: return null + val window = tablesState.windows[windowId] ?: return null + return windowId to window +} + +@Composable +private fun SidebarPaneDividerToggleButton( + tablesVm: TablesViewModel, + tablesState: TablesState, + selectedTableId: Uuid?, + modifier: Modifier = Modifier +) { + val scope = rememberCoroutineScope() + val selection = resolveSelectedWindow(tablesState, selectedTableId) ?: return + val (windowId, window) = selection + val currentVisible = window.layout.leftVisible + val currentWeight = (window.layout.leftRegion.size as WindowLayoutRegionSize.Weight).v1 + + val (nextVisible, nextWeight) = + when { + !currentVisible -> { + true to SidebarLayoutConstants.DEFAULT_SIDEBAR_WEIGHT + } + + currentWeight > SidebarLayoutConstants.SIDEBAR_EXPANDED_THRESHOLD -> { + true to SidebarLayoutConstants.COLLAPSED_SIDEBAR_WEIGHT + } + + else -> { + false to SidebarLayoutConstants.DEFAULT_SIDEBAR_WEIGHT + } + } + + IconButton( + onClick = { + scope.launch { + tablesVm.tablesRepo.setWindow( + windowId, + window.copy( + layout = + window.layout.copy( + leftVisible = nextVisible, + leftRegion = + window.layout.leftRegion.copy( + size = + WindowLayoutRegionSize.Weight(nextWeight) + ) + ) + ) + ) + } + }, + modifier = modifier.width(28.dp).height(28.dp) + ) { + Icon( + imageVector = if (currentVisible) Icons.Default.MenuOpen else Icons.Default.Menu, + contentDescription = "Toggle sidebar" + ) + } +} + @OptIn(ExperimentalMaterial3Api::class) @Composable fun SidebarContent(navController: NavHostController, modifier: Modifier = Modifier) { @@ -540,149 +605,157 @@ fun SidebarContent(navController: NavHostController, modifier: Modifier = Modifi widthPx = it.width } ) { - if (isWide) { - var selectedSidebarPane by remember { mutableIntStateOf(1) } - - // Wide mode: navigation row + tabbed pane (tabs/progress) - PermanentDrawerSheet( - modifier = - Modifier - .widthIn(min = 240.dp) - .fillMaxSize() + Column( + modifier = Modifier.fillMaxSize(), + ) { + Box( + modifier = Modifier.fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 8.dp) + .align(Alignment.Start) ) { - Column( - modifier = Modifier.fillMaxSize() + SidebarMenuButton() + } + if (isWide) { + var selectedSidebarPane by remember { mutableIntStateOf(1) } + + // Wide mode: navigation row + tabbed pane (tabs/progress) + PermanentDrawerSheet( + modifier = + Modifier + .widthIn(min = 240.dp) + .fillMaxSize() ) { - Row( - modifier = Modifier.fillMaxWidth().padding(horizontal = 8.dp, vertical = 8.dp), - horizontalArrangement = Arrangement.SpaceEvenly - ) { - allSidebarFeatures.forEach { item -> - val featureRoute = getRouteForFeature(item) - val isDocEditorRoute = currentRoute == AppScreens.DocEditor.name - val isSelected = - when { - featureRoute == null -> false - featureRoute == currentRoute -> true - item.key == FeatureKeys.Drawer && isDocEditorRoute -> true - else -> false - } - NavigationRailItem( - selected = isSelected, - onClick = { - scope.launch { - if (item.enabled) { - item.onActivate() - } - } - }, - enabled = item.enabled, - icon = { item.icon() }, - label = { item.labelContent?.invoke() ?: Text(item.label) }, - alwaysShowLabel = false - ) - } - } - HorizontalDivider() - Row( - modifier = Modifier.fillMaxWidth().weight(1f) + Column( + modifier = Modifier.fillMaxSize() ) { - NavigationRail( - modifier = Modifier.fillMaxHeight().padding(top = 8.dp) + Row( + modifier = Modifier.fillMaxWidth().padding(horizontal = 8.dp, vertical = 8.dp), + horizontalArrangement = Arrangement.SpaceEvenly ) { - NavigationRailItem( - selected = selectedSidebarPane == 2, - onClick = { selectedSidebarPane = 2 }, - icon = { Icon(Icons.Default.FolderOpen, contentDescription = "Drawer") }, - label = { Text("Drawer") }, - alwaysShowLabel = true - ) - NavigationRailItem( - selected = selectedSidebarPane == 1, - onClick = { selectedSidebarPane = 1 }, - icon = { Icon(Icons.Default.MoreVert, contentDescription = "Progress") }, - label = { Text("Progress") }, - alwaysShowLabel = true - ) + allSidebarFeatures.forEach { item -> + val featureRoute = getRouteForFeature(item) + val isDocEditorRoute = currentRoute == AppScreens.DocEditor.name + val isSelected = + when { + featureRoute == null -> false + featureRoute == currentRoute -> true + item.key == FeatureKeys.Drawer && isDocEditorRoute -> true + else -> false + } + NavigationRailItem( + selected = isSelected, + onClick = { + scope.launch { + if (item.enabled) { + item.onActivate() + } + } + }, + enabled = item.enabled, + icon = { item.icon() }, + label = { item.labelContent?.invoke() ?: Text(item.label) }, + alwaysShowLabel = false + ) + } } - VerticalDivider() - Column( - modifier = Modifier.weight(1f).fillMaxHeight() + HorizontalDivider() + Row( + modifier = Modifier.fillMaxWidth().weight(1f) ) { - val paneTitle = - when (selectedSidebarPane) { - 1 -> "Progress" - else -> "Drawer" - } - Row( - modifier = Modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 8.dp), - verticalAlignment = Alignment.CenterVertically + NavigationRail( + modifier = Modifier.fillMaxHeight().padding(top = 8.dp) ) { - Text( - text = paneTitle, - style = MaterialTheme.typography.titleSmall + NavigationRailItem( + selected = selectedSidebarPane == 2, + onClick = { selectedSidebarPane = 2 }, + icon = { Icon(Icons.Default.FolderOpen, contentDescription = "Drawer") }, + label = { Text("Drawer") }, + alwaysShowLabel = true + ) + NavigationRailItem( + selected = selectedSidebarPane == 1, + onClick = { selectedSidebarPane = 1 }, + icon = { Icon(Icons.Default.MoreVert, contentDescription = "Progress") }, + label = { Text("Progress") }, + alwaysShowLabel = true ) } - HorizontalDivider() - when (selectedSidebarPane) { - 1 -> { - ProgressList(modifier = Modifier.weight(1f).fillMaxWidth()) + VerticalDivider() + Column( + modifier = Modifier.weight(1f).fillMaxHeight() + ) { + val paneTitle = + when (selectedSidebarPane) { + 1 -> "Progress" + else -> "Drawer" + } + Row( + modifier = Modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = paneTitle, + style = MaterialTheme.typography.titleSmall + ) } + HorizontalDivider() + when (selectedSidebarPane) { + 1 -> { + ProgressList(modifier = Modifier.weight(1f).fillMaxWidth()) + } - else -> { - DocList( - drawerViewModel = drawerVm, - selectedDocId = selectedDrawerDocId, - onDocClick = { docId -> - docEditorStore.selectDoc(docId) - if (currentRoute != AppScreens.DocEditor.name) { - navController.navigate(AppScreens.DocEditor.name) { - launchSingleTop = true + else -> { + DocList( + drawerViewModel = drawerVm, + selectedDocId = selectedDrawerDocId, + onDocClick = { docId -> + docEditorStore.selectDoc(docId) + if (currentRoute != AppScreens.DocEditor.name) { + navController.navigate(AppScreens.DocEditor.name) { + launchSingleTop = true + } } - } - }, - modifier = Modifier.weight(1f).fillMaxWidth() - ) + }, + modifier = Modifier.weight(1f).fillMaxWidth() + ) + } } } } } } - } - } else { - // Narrow mode: NavigationRail with features only - NavigationRail( - modifier = - Modifier - .align(Alignment.Center) - .fillMaxHeight() - ) { - allSidebarFeatures.forEach { item -> - val featureRoute = getRouteForFeature(item) - val isDocEditorRoute = currentRoute == AppScreens.DocEditor.name - val isSelected = - when { - featureRoute == null -> false - featureRoute == currentRoute -> true - item.key == FeatureKeys.Drawer && isDocEditorRoute -> true - else -> false - } + } else { + // Narrow mode: NavigationRail with features only + NavigationRail( + modifier = Modifier.fillMaxHeight() + ) { + allSidebarFeatures.forEach { item -> + val featureRoute = getRouteForFeature(item) + val isDocEditorRoute = currentRoute == AppScreens.DocEditor.name + val isSelected = + when { + featureRoute == null -> false + featureRoute == currentRoute -> true + item.key == FeatureKeys.Drawer && isDocEditorRoute -> true + else -> false + } - NavigationRailItem( - selected = isSelected, - onClick = { - if (item.enabled) { - scope.launch { - item.onActivate() + NavigationRailItem( + selected = isSelected, + onClick = { + if (item.enabled) { + scope.launch { + item.onActivate() + } } - } - }, - enabled = item.enabled, - icon = { - item.icon() - }, - label = null // Rail mode: icon-only, no labels - ) + }, + enabled = item.enabled, + icon = { + item.icon() + }, + label = null // Rail mode: icon-only, no labels + ) + } } } } @@ -815,6 +888,7 @@ interface DockedRegionScope { key: Any, modifier: Modifier = Modifier, regimes: List = listOf(PaneSizeRegime.Continuous()), + dividerContent: (@Composable () -> Unit)? = null, content: @Composable (() -> Unit) ) } @@ -835,6 +909,7 @@ fun DockableRegion( val key: Any, val modifier: Modifier, val regimes: List, + val dividerContent: (@Composable () -> Unit)?, val content: @Composable (() -> Unit) ) @@ -845,9 +920,10 @@ fun DockableRegion( key: Any, modifier: Modifier, regimes: List, + dividerContent: (@Composable () -> Unit)?, content: @Composable (() -> Unit) ) { - items.add(RegionPaneData(key, modifier, regimes, content)) + items.add(RegionPaneData(key, modifier, regimes, dividerContent, content)) } } @@ -874,62 +950,11 @@ fun DockableRegion( private var dragStartSizeDpB: Float? = null private var dragStartKeyA: Any? = null private var dragStartKeyB: Any? = null + private var lastTotalSizePx: Int = 0 // Accessor to get weight for a specific key fun getWeight(key: Any): Float = weightMap[key] ?: 1f - private fun logDiscreteWeightConversion( - phase: String, - keyA: Any, - keyB: Any, - regime: PaneSizeRegime.Discrete, - virtualSizeDpA: Float, - virtualSizeDpB: Float, - totalSizeDp: Float, - totalWeight: Float, - totalCurrentWeight: Float, - targetWeightA: Float, - targetWeightB: Float, - newWeightA: Float, - newWeightB: Float - ) { - println( - buildString { - append("[DOCKABLE_DISCRETE] phase=") - append(phase) - append(" keyA=") - append(keyA) - append(" keyB=") - append(keyB) - append(" regime={minDp=") - append(regime.minDp) - append(", maxDp=") - append(regime.maxDp) - append(", sizeDp=") - append(regime.sizeDp) - append("}") - append(" virtualSizeDpA=") - append(virtualSizeDpA) - append(" virtualSizeDpB=") - append(virtualSizeDpB) - append(" totalSizeDp=") - append(totalSizeDp) - append(" totalWeight=") - append(totalWeight) - append(" totalCurrentWeight=") - append(totalCurrentWeight) - append(" targetWeightA=") - append(targetWeightA) - append(" targetWeightB=") - append(targetWeightB) - append(" newWeightA=") - append(newWeightA) - append(" newWeightB=") - append(newWeightB) - } - ) - } - private fun sizeDpToWeight(sizeDp: Float, totalSizeDp: Float, totalWeight: Float): Float = if (totalSizeDp <= 0f) 0f else (sizeDp / totalSizeDp) * totalWeight @@ -1007,19 +1032,6 @@ fun DockableRegion( sizeDpMap[key] = getSizeDp(key, totalSizePx, newTotalWeight) } - discreteTargets.forEach { (key, value) -> - val regime = value.first - val currentWeight = currentWeights.getValue(key) - val appliedWeight = appliedDiscreteWeights.getValue(key) - println( - "[DOCKABLE_DISCRETE] phase=reconcile/resize key=$key " + - "regime={minDp=${regime.minDp}, maxDp=${regime.maxDp}, sizeDp=${regime.sizeDp}} " + - "totalSizeDp=$totalSizeDp totalWeight=$totalWeight currentWeight=$currentWeight " + - "targetWeight=${value.second} appliedWeight=$appliedWeight fixedScale=$fixedScale " + - "appliedDiscreteSum=$appliedDiscreteSum remainingBudget=$remainingBudget " + - "remainingCurrentSum=$remainingCurrentSum newTotalWeight=$newTotalWeight" - ) - } } // Get current size in dp for a key @@ -1047,6 +1059,7 @@ fun DockableRegion( // The Logic: Syncs the incoming N items with our storage fun reconcile(keys: List, totalSizePx: Int) { + lastTotalSizePx = totalSizePx val totalWeight = keys.sumOf { getWeight(it).toDouble() }.toFloat() keys.forEach { key -> @@ -1088,6 +1101,7 @@ fun DockableRegion( // End drag - resolve final handle position based on regimes // Uses the virtual handle position to determine final sizes fun endDrag(keys: List, indexA: Int, totalSizePx: Int) { + lastTotalSizePx = totalSizePx val keyA = keys[indexA] val keyB = keys[indexA + 1] @@ -1130,21 +1144,6 @@ fun DockableRegion( val newWeightB = (totalCurrentWeight - newWeightA).coerceAtLeast( SidebarLayoutConstants.MIN_PANE_WEIGHT ) - logDiscreteWeightConversion( - phase = "endDrag/bothDiscrete/AWins", - keyA = keyA, - keyB = keyB, - regime = regimeA, - virtualSizeDpA = virtualSizeDpA, - virtualSizeDpB = virtualSizeDpB, - totalSizeDp = totalSizeDp, - totalWeight = totalWeight, - totalCurrentWeight = totalCurrentWeight, - targetWeightA = targetWeightA, - targetWeightB = targetWeightB, - newWeightA = newWeightA, - newWeightB = newWeightB - ) weightMap[keyA] = newWeightA weightMap[keyB] = newWeightB } else { @@ -1156,21 +1155,6 @@ fun DockableRegion( val newWeightA = (totalCurrentWeight - newWeightB).coerceAtLeast( SidebarLayoutConstants.MIN_PANE_WEIGHT ) - logDiscreteWeightConversion( - phase = "endDrag/bothDiscrete/BWins", - keyA = keyA, - keyB = keyB, - regime = regimeB, - virtualSizeDpA = virtualSizeDpA, - virtualSizeDpB = virtualSizeDpB, - totalSizeDp = totalSizeDp, - totalWeight = totalWeight, - totalCurrentWeight = totalCurrentWeight, - targetWeightA = targetWeightA, - targetWeightB = targetWeightB, - newWeightA = newWeightA, - newWeightB = newWeightB - ) weightMap[keyA] = newWeightA weightMap[keyB] = newWeightB } @@ -1206,21 +1190,6 @@ fun DockableRegion( val newWeightB = (totalCurrentWeight - newWeightA).coerceAtLeast( SidebarLayoutConstants.MIN_PANE_WEIGHT ) - logDiscreteWeightConversion( - phase = "endDrag/discreteA", - keyA = keyA, - keyB = keyB, - regime = regimeA, - virtualSizeDpA = virtualSizeDpA, - virtualSizeDpB = virtualSizeDpB, - totalSizeDp = totalSizeDp, - totalWeight = totalWeight, - totalCurrentWeight = totalCurrentWeight, - targetWeightA = targetWeightA, - targetWeightB = targetWeightB, - newWeightA = newWeightA, - newWeightB = newWeightB - ) weightMap[keyA] = newWeightA weightMap[keyB] = newWeightB } else { @@ -1255,21 +1224,6 @@ fun DockableRegion( val newWeightA = (totalCurrentWeight - newWeightB).coerceAtLeast( SidebarLayoutConstants.MIN_PANE_WEIGHT ) - logDiscreteWeightConversion( - phase = "endDrag/discreteB", - keyA = keyA, - keyB = keyB, - regime = regimeB, - virtualSizeDpA = virtualSizeDpA, - virtualSizeDpB = virtualSizeDpB, - totalSizeDp = totalSizeDp, - totalWeight = totalWeight, - totalCurrentWeight = totalCurrentWeight, - targetWeightA = targetWeightA, - targetWeightB = targetWeightB, - newWeightA = newWeightA, - newWeightB = newWeightB - ) weightMap[keyA] = newWeightA weightMap[keyB] = newWeightB } else { @@ -1326,6 +1280,9 @@ fun DockableRegion( weightMap[k] = v } } + if (lastTotalSizePx > 0) { + rebalanceDiscreteWeights(paneRegimes.keys.toList(), lastTotalSizePx) + } } } @@ -1336,6 +1293,7 @@ fun DockableRegion( // Then apply discrete regime constraints if the virtual position falls within discrete ranges fun resize(keys: List, indexA: Int, delta: Float, totalSizePx: Int): Boolean { if (totalSizePx == 0) return false + lastTotalSizePx = totalSizePx val keyA = keys[indexA] val keyB = keys[indexA + 1] @@ -1421,21 +1379,6 @@ fun DockableRegion( val newWeightB = (totalCurrentWeight - newWeightA).coerceAtLeast( SidebarLayoutConstants.MIN_PANE_WEIGHT ) - logDiscreteWeightConversion( - phase = "resize/bothDiscrete/AWins", - keyA = keyA, - keyB = keyB, - regime = regimeA, - virtualSizeDpA = virtualSizeDpA, - virtualSizeDpB = virtualSizeDpB, - totalSizeDp = totalSizeDp, - totalWeight = totalWeight, - totalCurrentWeight = totalCurrentWeight, - targetWeightA = targetWeightA, - targetWeightB = targetWeightB, - newWeightA = newWeightA, - newWeightB = newWeightB - ) weightMap[keyA] = newWeightA weightMap[keyB] = newWeightB return true @@ -1454,21 +1397,6 @@ fun DockableRegion( val newWeightA = (totalCurrentWeight - newWeightB).coerceAtLeast( SidebarLayoutConstants.MIN_PANE_WEIGHT ) - logDiscreteWeightConversion( - phase = "resize/bothDiscrete/BWins", - keyA = keyA, - keyB = keyB, - regime = regimeB, - virtualSizeDpA = virtualSizeDpA, - virtualSizeDpB = virtualSizeDpB, - totalSizeDp = totalSizeDp, - totalWeight = totalWeight, - totalCurrentWeight = totalCurrentWeight, - targetWeightA = targetWeightA, - targetWeightB = targetWeightB, - newWeightA = newWeightA, - newWeightB = newWeightB - ) weightMap[keyA] = newWeightA weightMap[keyB] = newWeightB return true @@ -1546,113 +1474,34 @@ fun DockableRegion( } // return@forEachIndexed if (index < scope.items.size - 1) { - val interactionSource = remember { MutableInteractionSource() } - val isHovered by interactionSource.collectIsHoveredAsState() var dragStarted by remember { mutableStateOf(false) } - - val modifier = - Modifier - .pointerHoverIcon(PointerIcon.Hand) - .hoverable(interactionSource) - .pointerInput(Unit) { - if (orientation == Orientation.Horizontal) { - detectHorizontalDragGestures( - onDragStart = { - dragStarted = true - state.startDrag( - currentKeys[index], - currentKeys[index + 1], - totalSizePx - ) - }, - onDragEnd = { - if (dragStarted) { - state.endDrag(currentKeys, index, totalSizePx) - dragStarted = false - } - }, - onDragCancel = { - if (dragStarted) { - state.endDrag(currentKeys, index, totalSizePx) - dragStarted = false - } - }, - onHorizontalDrag = { - change: PointerInputChange, - dragAmount: Float - -> - change.consume() - state.resize(currentKeys, index, dragAmount, totalSizePx) - } - ) - } else { - detectVerticalDragGestures( - onDragStart = { - dragStarted = true - state.startDrag( - currentKeys[index], - currentKeys[index + 1], - totalSizePx - ) - }, - onDragEnd = { - if (dragStarted) { - state.endDrag(currentKeys, index, totalSizePx) - dragStarted = false - } - }, - onDragCancel = { - if (dragStarted) { - state.endDrag(currentKeys, index, totalSizePx) - dragStarted = false - } - }, - onVerticalDrag = { - change: PointerInputChange, - dragAmount: Float - -> - change.consume() - state.resize(currentKeys, index, dragAmount, totalSizePx) - } - ) - } - } - Box( - modifier = - when (orientation) { - Orientation.Horizontal -> { - modifier - .width(8.dp) - } - - Orientation.Vertical -> { - modifier - .height(8.dp) - } - } - ) { - val color = - if (isHovered) { - MaterialTheme.colorScheme.primary.copy(alpha = 0.3f) - } else { - MaterialTheme.colorScheme.outline.copy(alpha = 0.1f) - } - when (orientation) { - Orientation.Horizontal -> { - VerticalDivider( - modifier = Modifier.align(Alignment.Center), - color = color - ) + DockableDivider( + orientation = orientation, + onDragStart = { + dragStarted = true + state.startDrag( + currentKeys[index], + currentKeys[index + 1], + totalSizePx + ) + }, + onDrag = { dragAmount -> + state.resize(currentKeys, index, dragAmount, totalSizePx) + }, + onDragEnd = { + if (dragStarted) { + state.endDrag(currentKeys, index, totalSizePx) + dragStarted = false } - - Orientation.Vertical -> { - HorizontalDivider( - modifier = Modifier.align(Alignment.Center), - color = color - ) + }, + onDragCancel = { + if (dragStarted) { + state.endDrag(currentKeys, index, totalSizePx) + dragStarted = false } - } - } + }, + centerContent = scope.items[index].dividerContent + ) } } } diff --git a/src/daybook_compose/composeApp/src/commonMain/kotlin/org/example/daybook/uniffi/core/daybook_core.kt b/src/daybook_compose/composeApp/src/commonMain/kotlin/org/example/daybook/uniffi/core/daybook_core.kt index 0e882c7d..b3af8aa9 100644 --- a/src/daybook_compose/composeApp/src/commonMain/kotlin/org/example/daybook/uniffi/core/daybook_core.kt +++ b/src/daybook_compose/composeApp/src/commonMain/kotlin/org/example/daybook/uniffi/core/daybook_core.kt @@ -5815,10 +5815,3 @@ public object FfiConverterTypeUuid: FfiConverter { */ public typealias VersionTag = kotlin.String public typealias FfiConverterTypeVersionTag = FfiConverterString - - - - - - - diff --git a/src/daybook_compose/composeApp/src/commonMain/kotlin/org/example/daybook/uniffi/daybook_ffi.kt b/src/daybook_compose/composeApp/src/commonMain/kotlin/org/example/daybook/uniffi/daybook_ffi.kt index 6dd8c03a..9a8ed980 100644 --- a/src/daybook_compose/composeApp/src/commonMain/kotlin/org/example/daybook/uniffi/daybook_ffi.kt +++ b/src/daybook_compose/composeApp/src/commonMain/kotlin/org/example/daybook/uniffi/daybook_ffi.kt @@ -11528,66 +11528,3 @@ public object FfiConverterTypeUuid: FfiConverter { FfiConverterByteArray.write(builtinValue, buf) } } - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/src/daybook_compose/composeApp/src/commonMain/kotlin/org/example/daybook/uniffi/types/daybook_types.kt b/src/daybook_compose/composeApp/src/commonMain/kotlin/org/example/daybook/uniffi/types/daybook_types.kt index 6ce412db..e832dddd 100644 --- a/src/daybook_compose/composeApp/src/commonMain/kotlin/org/example/daybook/uniffi/types/daybook_types.kt +++ b/src/daybook_compose/composeApp/src/commonMain/kotlin/org/example/daybook/uniffi/types/daybook_types.kt @@ -3243,4 +3243,3 @@ public object FfiConverterTypeUuid: FfiConverter { FfiConverterByteArray.write(builtinValue, buf) } } - diff --git a/src/daybook_compose/composeApp/src/desktopMain/kotlin/org/example/daybook/main.kt b/src/daybook_compose/composeApp/src/desktopMain/kotlin/org/example/daybook/main.kt index bf085e2c..2a757edc 100644 --- a/src/daybook_compose/composeApp/src/desktopMain/kotlin/org/example/daybook/main.kt +++ b/src/daybook_compose/composeApp/src/desktopMain/kotlin/org/example/daybook/main.kt @@ -102,7 +102,10 @@ fun main() = application { shutdownDone = true EventQueue.invokeLater { exitApplication() } }, - autoShutdownOnDispose = false + autoShutdownOnDispose = false, + onExitRequest = { + signalShutdownRequested.set(true) + } ) } }