diff --git a/README.md b/README.md index 913aa42c..0c6a0466 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 sandboxing/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/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_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 78bba22d..fe3c9498 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,48 @@ 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() + + 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/")) + doFirst { + if (!sourceSoFile.exists()) { + throw GradleException("Missing Rust Android library: ${sourceSoFile.absolutePath}") + } } - val androidNdkRoot = System.getenv("ANDROID_NDK_ROOT") - val libcxxSourceFile = - if (!androidNdkRoot.isNullOrBlank()) ndkLibCppSharedForAbi(targetAbi, androidNdkRoot) else null + from(sourceSoFile) - doFirst { - if (!sourceSoFile.exists()) { - throw GradleException("Missing Rust Android library: ${sourceSoFile.absolutePath}") + val androidNdkRoot = System.getenv("ANDROID_NDK_ROOT") + val libcxxSourceFile = + if (!androidNdkRoot.isNullOrBlank()) ndkLibCppSharedForAbi(targetAbi, androidNdkRoot) else null + if (libcxxSourceFile != null && libcxxSourceFile.exists()) { + from(libcxxSourceFile) } - destDir.mkdirs() - destSoFile.delete() - destLibcxxFile.delete() + + 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/androidMain/kotlin/org/example/daybook/MainActivity.kt b/src/daybook_compose/composeApp/src/androidMain/kotlin/org/example/daybook/MainActivity.kt index 7a4c0cab..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 @@ -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 = 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 55482d33..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 @@ -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 @@ -83,6 +87,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 @@ -92,6 +101,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 @@ -116,8 +126,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 @@ -139,6 +155,7 @@ enum class DaybookContentType { } val LocalPermCtx = compositionLocalOf { null } +val LocalAppExitRequest = compositionLocalOf<(() -> Unit)?> { null } data class PermissionsContext( val hasCamera: Boolean = false, @@ -168,11 +185,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 ) @@ -181,6 +198,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 { @@ -189,7 +216,8 @@ enum class AppScreens { Tables, Progress, Settings, - Drawer + Drawer, + DocEditor } private sealed interface AppInitState { @@ -530,15 +558,160 @@ 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, + onExitRequest: (() -> Unit)? = null ) { 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 +722,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 +749,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 +765,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 +783,112 @@ 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")) + } 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 +899,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 +909,80 @@ 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 + + var loadedSyncRepo: SyncRepoFfi? = null + try { + println("[APP_INIT] stage=deferred SyncRepoFfi.load start") + loadedSyncRepo = 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") + 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) + } + } + DaybookTheme(themeConfig = config.theme) { when (val state = initState) { is AppInitState.Loading -> { @@ -765,62 +1039,88 @@ fun App( is AppInitState.Ready -> { val appContainer = state.container + val containerKey = "container:${appContainer.ffiCtx}" + val drawerVm: DrawerViewModel = + viewModel(key = "drawerVm:$containerKey") { DrawerViewModel(appContainer.drawerRepo) } + val docEditorStore: DocEditorStoreViewModel = + viewModel(key = "docEditorStoreVm:$containerKey") { DocEditorStoreViewModel(appContainer.drawerRepo) } + 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, + LocalDrawerViewModel provides drawerVm, + LocalDocEditorStore provides docEditorStore + ) { + 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, + LocalAppExitRequest provides onExitRequest + ) { + 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 +1133,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 +1152,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 +1186,41 @@ 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) { + println("[APP_SHUTDOWN] flushing to disk: stopping sync repo") + 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() + } + println("[APP_SHUTDOWN] flushing to disk: closing repo handles") + appContainer.drawerRepo.close() + appContainer.tablesRepo.close() + appContainer.dispatchRepo.close() + appContainer.progressRepo.close() + 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 +1493,8 @@ fun Routes( extraAction: (() -> Unit)? = null, navController: NavHostController ) { + val drawerVm = LocalDrawerViewModel.current + NavHost( startDestination = AppScreens.Home.name, navController = navController @@ -1168,9 +1516,50 @@ 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(modifier = modifier, contentType = contentType) + DrawerScreen( + drawerVm = drawerVm, + onOpenDoc = { + navController.navigate(AppScreens.DocEditor.name) { launchSingleTop = true } + }, + modifier = modifier, + ) + } + } + 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", + onBack = { navController.popBackStack() } + ) + ) { + DocEditorScreen( + contentType = contentType, + modifier = modifier + ) } } 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/DocEditorStoreViewModel.kt b/src/daybook_compose/composeApp/src/commonMain/kotlin/org/example/daybook/DocEditorStoreViewModel.kt new file mode 100644 index 00000000..5d700da9 --- /dev/null +++ b/src/daybook_compose/composeApp/src/commonMain/kotlin/org/example/daybook/DocEditorStoreViewModel.kt @@ -0,0 +1,155 @@ +@file:OptIn(kotlin.time.ExperimentalTime::class) + +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 +import java.util.concurrent.ConcurrentHashMap +import org.example.daybook.ui.editor.EditorSessionController +import org.example.daybook.uniffi.DrawerEventListener +import org.example.daybook.uniffi.DrawerRepoFfi +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 = ConcurrentHashMap() + + private val _selectedDocId = MutableStateFlow(null) + val selectedDocId = _selectedDocId.asStateFlow() + + private val _selectedController = MutableStateFlow(null) + val selectedController = _selectedController.asStateFlow() + + private var listenerRegistration: ListenerRegistration? = null + private var registerJob: Job? = 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 { + registerJob = + viewModelScope.launch { + val registration = drawerRepo.ffiRegisterListener(listener) + if (!isActive) { + registration.unregister() + return@launch + } + listenerRegistration = registration + } + viewModelScope.launch { + while (true) { + delay(30_000) + evictIdleSessions() + } + } + } + + fun selectDoc(docId: String?) { + _selectedDocId.value = docId + if (docId == null) { + _selectedController.value = null + return + } + + val entry = createSession(docId) + entry.lastTouchedMs = nowMs() + _selectedController.value = entry.controller + viewModelScope.launch { refreshDoc(docId) } + } + + fun attachHost(docId: String) { + val entry = 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 { + return sessions.computeIfAbsent(docId) { + val controller = + EditorSessionController( + drawerRepo = drawerRepo, + scope = viewModelScope, + onDocCreated = { createdId -> selectDoc(createdId) } + ) + DocEditorSessionEntry(controller = controller) + } + } + + private suspend fun refreshDoc(docId: String) { + val entry = sessions[docId] ?: return + val bundle = drawerRepo.getBundle(docId, "main") + entry.controller.bindDoc(bundle?.doc, bundle) + entry.lastTouchedMs = nowMs() + } + + private fun evictIdleSessions() { + val selectedId = _selectedDocId.value + val now = nowMs() + val toRemove = mutableListOf() + for ((docId, entry) in sessions.entries) { + 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() { + registerJob?.cancel() + listenerRegistration?.unregister() + super.onCleared() + } +} 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/CaptureNavActions.kt b/src/daybook_compose/composeApp/src/commonMain/kotlin/org/example/daybook/capture/CaptureNavActions.kt new file mode 100644 index 00000000..03707f4e --- /dev/null +++ b/src/daybook_compose/composeApp/src/commonMain/kotlin/org/example/daybook/capture/CaptureNavActions.kt @@ -0,0 +1,17 @@ +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 1019efd0..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 @@ -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 @@ -25,6 +28,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 @@ -46,14 +50,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, @@ -70,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,23 +85,53 @@ class CaptureScreenViewModel( persistCaptureMode(mode) } + fun cycleCaptureMode() { + val next = + when (_captureMode.value) { + CaptureMode.TEXT -> CaptureMode.CAMERA + CaptureMode.CAMERA -> CaptureMode.MIC + CaptureMode.MIC -> CaptureMode.TEXT + } + setCaptureMode(next) + } + 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.tryEmit("Failed to persist capture mode") } } } @@ -148,18 +174,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") @@ -169,9 +191,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 +200,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 +221,6 @@ class CaptureScreenViewModel( } init { - loadLatestDocs() if (initialDocId != null) { loadDoc(initialDocId) } else { @@ -234,30 +252,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() @@ -281,6 +275,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) { @@ -296,12 +296,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/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/drawer/DrawerScreen.kt b/src/daybook_compose/composeApp/src/commonMain/kotlin/org/example/daybook/drawer/DrawerScreen.kt index 0768ba3a..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,26 +10,19 @@ 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 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,190 +30,125 @@ 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(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) - } - - 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) +fun DrawerScreen( + drawerVm: DrawerViewModel, + onOpenDoc: (String) -> Unit, + modifier: Modifier = Modifier +) { + 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!!) } } } + + 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/progress/ProgressScreen.kt b/src/daybook_compose/composeApp/src/commonMain/kotlin/org/example/daybook/progress/ProgressScreen.kt index 0f67edf7..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 @@ -18,6 +19,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,8 +42,10 @@ 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.mutableIntStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue @@ -50,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 @@ -128,6 +134,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 +326,170 @@ 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 density = LocalDensity.current + var pinnedHeaderHeightPx by remember { mutableIntStateOf(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), + contentPadding = + if (showPinnedTimelineHeader) { + PaddingValues(top = with(density) { pinnedHeaderHeightPx.toDp() }) + } else { + PaddingValues(0.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) + .onSizeChanged { pinnedHeaderHeightPx = it.height } + ) { + Column(modifier = Modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 12.dp)) { + Text("Timeline", style = MaterialTheme.typography.titleSmall) + Spacer(Modifier.height(6.dp)) + HorizontalDivider() + } } } } @@ -513,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 64560b8c..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 @@ -11,38 +11,42 @@ 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.interaction.MutableInteractionSource 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 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 +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.foundation.selection.selectable +import androidx.compose.ui.semantics.Role +import androidx.compose.material3.LocalTextStyle 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 @@ -62,6 +66,11 @@ fun RowScope.CenterNavBarContent( modifier: Modifier = Modifier ) { 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 @@ -95,27 +104,37 @@ 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, - enabled = button.enabled + selected = selected, + hover = hoverOver, + armed = hoverOver && readyState, + enabled = button.enabled, + hoverFill = hoverFill, + selectedFill = selectedFill, + armedIndicatorColor = armedIndicatorColor, + icon = button.icon, + label = { + androidx.compose.runtime.CompositionLocalProvider( + LocalTextStyle provides MaterialTheme.typography.labelSmall + ) { + button.label() + } + }, + onClick = { + if (button.enabled) { + scope.launch { button.onClick() } + } + } ) } } @@ -154,30 +173,107 @@ 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 + 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 outerInteraction = remember { MutableInteractionSource() } + val background = + when { + selected -> selectedFill + hover -> hoverFill + else -> androidx.compose.ui.graphics.Color.Transparent + } + + Box( + modifier = + modifier + .selectable( + selected = selected, + onClick = onClick, + enabled = enabled, + interactionSource = outerInteraction, + indication = null, + role = Role.Tab + ) + .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(56.dp) + .clip(itemShape) + .background(background) + .then( + if (armed) { + Modifier.border(width = 1.5.dp, color = armedIndicatorColor, shape = itemShape) + } else { + Modifier + } + ) + .padding(horizontal = 10.dp, vertical = 6.dp), + contentAlignment = Alignment.Center + ) { + Box { icon() } + } + Box { label() } + } + } +} + +private fun isFeatureRouteSelected(featureKey: String, currentRoute: String?): Boolean { + val targetRoute = routeForFeatureKey(featureKey) + return targetRoute != null && targetRoute == currentRoute +} 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 b900f95f..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 @@ -4,52 +4,65 @@ 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 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 +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.Progress -> AppScreens.Progress.name + FeatureKeys.Settings -> AppScreens.Settings.name + else -> null + } /** * All available features. This is the master list. */ @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) - } - }, - FeatureItem(FeatureKeys.Capture, { Icon(Icons.Default.CameraAlt, contentDescription = "Capture") }, "Capture") { - scope.launch { - navController.navigate(AppScreens.Capture.name) - } - }, - FeatureItem(FeatureKeys.Drawer, { Icon(Icons.AutoMirrored.Filled.LibraryBooks, contentDescription = "Drawer") }, "Drawer") { - scope.launch { - navController.navigate(AppScreens.Drawer.name) - } - }, - FeatureItem(FeatureKeys.Tables, { Icon(Icons.Default.TableChart, contentDescription = "Tables") }, "Tables") { - scope.launch { - navController.navigate(AppScreens.Tables.name) - } - }, - FeatureItem(FeatureKeys.Progress, { Icon(Icons.Default.Notifications, contentDescription = "Progress") }, "Progress") { - scope.launch { - 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.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) } + ) ) } @@ -94,7 +107,6 @@ fun rememberMenuFeatures( navController: NavHostController, onShowCloneShare: () -> Unit = {} ): List { - val scope = rememberCoroutineScope() val allFeatures = rememberAllFeatures(navController) // Get the bottom bar features (Home, Capture, Documents) @@ -105,16 +117,22 @@ fun rememberMenuFeatures( return otherFeatures + listOf( - FeatureItem(FeatureKeys.CloneShare, { Icon(Icons.Default.QrCode2, contentDescription = "Clone") }, "Clone") { - scope.launch { - onShowCloneShare() - } - }, - FeatureItem(FeatureKeys.Settings, { Icon(Icons.Default.Settings, contentDescription = "Settings") }, "Settings") { - scope.launch { - 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 new file mode 100644 index 00000000..c6ff9091 --- /dev/null +++ b/src/daybook_compose/composeApp/src/commonMain/kotlin/org/example/daybook/tables/FloatingBottomBar.kt @@ -0,0 +1,330 @@ +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.border +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.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 +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.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.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.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 = 64.dp + val horizontalPadding = 16.dp + val verticalPadding = 8.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, + onBarHeightChanged: (Dp) -> Unit = {}, + 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() + .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.fillMaxWidth().padding(horizontal = 20.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, + barHeight: Dp, + menuItems: List, + highlightedMenuItem: String?, + activationReadyMenuItem: 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 + val maxMenuHeight = remember { 560.dp } + val maxMenuHeightPx = with(density) { maxMenuHeight.toPx().coerceAtLeast(1f) } + 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) + val targetHeight = (maxMenuHeight * openFraction) + val dragModifier = + 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() + } + } + } + } + ) + val barDragAreaHeight = barHeight + FloatingBarDefaults.verticalPadding * 2 + 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 + ) { + 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 + .fillMaxWidth() + .verticalScroll(rememberScrollState()), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + menuItems.forEach { item -> + val isActivationReady = item.key == activationReadyMenuItem + NavigationDrawerItem( + selected = item.key == highlightedMenuItem, + onClick = { + scope.launch { + if (item.enabled) { + onItemActivate(item) + } + } + }, + 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()) + } + ) + } + Spacer(Modifier.height(bottomInset)) + } + } + } + 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/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/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 7ab78e76..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 @@ -89,9 +89,12 @@ 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.delay 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 @@ -183,8 +186,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 @@ -213,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) } @@ -226,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()) } @@ -248,6 +255,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 } @@ -271,6 +283,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 @@ -278,18 +293,26 @@ 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 + menuSheetOpenedByDrag = menuSheetState.isVisible horizontalDragDistance = 0f + hoveredMenuItemKey = null + activationReadyMenuItem = null + hoverActivationJob?.cancel() + hoverActivationJob = null }, onDrag = { change, dragAmount -> 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) { @@ -304,7 +327,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) -> @@ -343,56 +383,61 @@ 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 = menuFeatures.find { it.key == menuItemKey } - if (feature != null) { + val feature = allMenuItems.find { it.key == menuItemKey } + val hoveredLongEnough = menuItemKey != null && activationReadyMenuItem == menuItemKey + if (feature != null && feature.enabled && 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) { + if (feature != null && feature.enabled) { 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 if (shouldClose) { - revealSheetState.hide() + menuCloseDragEnabled = false + menuSheetState.hide() } else { - revealSheetState.settle(0f) + menuCloseDragEnabled = true + menuSheetState.settle(0f) } } }, @@ -402,10 +447,15 @@ 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 - revealSheetState.hide() + menuCloseDragEnabled = false + menuSheetState.hide() showFeaturesMenu = false } } @@ -415,6 +465,16 @@ fun CompactLayout( val tablesRepo = LocalContainer.current.tablesRepo val vm = viewModel { TablesViewModel(tablesRepo) } + LaunchedEffect(isDocEditorFullscreen) { + if (isDocEditorFullscreen) { + tabsSheetState.hide() + menuSheetState.hide() + menuCloseDragEnabled = false + leftDrawerState.close() + showFeaturesMenu = false + } + } + // Clear cached tab layout rects whenever the selected table or sheet content changes // FIXME: LaunchedEffect(vm.tablesState.collectAsState(), sheetContent) { @@ -424,19 +484,31 @@ fun CompactLayout( highlightedTab = null highlightedTable = null highlightedMenuItem = null + activationReadyMenuItem = null } // 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 + activationReadyMenuItem = 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, @@ -450,8 +522,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() } } } @@ -495,31 +567,39 @@ fun CompactLayout( Scaffold( modifier = modifier, bottomBar = { - DaybookBottomNavigationBar( - centerContent = { - centerNavBarContent() - }, - // showLeftDrawerHint = leftDrawerState.currentValue == DrawerValue.Closed && !isLeftDrawerDragging, - showLeftDrawerHint = true, - bottomBarModifier = menuGestureModifier, - ) + if (!isDocEditorFullscreen) { + val menuOpenProgress = + if (menuSheetState.isVisible) { + (menuSheetState.progress / SheetConfig.MENU_MAX_ANCHOR).coerceIn(0f, 1f) + } else { + 0f + } + FloatingBottomNavigationBar( + centerContent = { + centerNavBarContent() + }, + menuOpenProgress = menuOpenProgress, + onBarHeightChanged = { floatingBarHeight = it }, + 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 @@ -594,55 +674,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 @@ -671,23 +706,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 -> @@ -707,37 +743,69 @@ 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, + barHeight = floatingBarHeight, + menuItems = allMenuItems, + highlightedMenuItem = highlightedMenuItem, + activationReadyMenuItem = activationReadyMenuItem, + enableDragToClose = menuCloseDragEnabled, + onMenuItemLayout = { key, rect -> + menuItemLayouts = menuItemLayouts + (key to rect) + }, + onDismiss = { + scope.launch { + menuCloseDragEnabled = false + highlightedMenuItem = null + activationReadyMenuItem = null + lastDragWindowPos = null + showFeaturesMenu = false + menuSheetState.hide() + } + }, + onItemActivate = { item -> + if (item.enabled) { + item.onActivate() + } + menuCloseDragEnabled = false + showFeaturesMenu = false + activationReadyMenuItem = null + menuSheetState.hide() + }, + modifier = Modifier.fillMaxSize() + ) } } @@ -856,17 +924,27 @@ 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", - 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) { - 0 -> { + 1 -> { + ProgressList(modifier = Modifier.weight(1f).fillMaxWidth()) + } + + else -> { selectedTable?.let { table -> Text( text = table.title, @@ -881,52 +959,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 69b5cb5a..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 @@ -25,6 +20,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 @@ -58,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 @@ -74,11 +66,16 @@ 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.dockable.DockableDivider 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 @@ -86,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 @@ -103,8 +102,8 @@ private object SidebarLayoutConstants { /** Minimum weight for any pane to prevent it from disappearing */ 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 @@ -326,9 +325,6 @@ fun ExpandedLayout( Icon(Icons.Filled.Add, "Large floating action button") } } - }, - topBar = { - ChromeStateTopAppBar(mergedChromeState) } ) { innerPadding -> if (layoutConfig != null) { @@ -337,6 +333,7 @@ fun ExpandedLayout( tablesVm = tablesVm, navController = navController, extraAction = extraAction, + chromeState = mergedChromeState, modifier = Modifier.padding(innerPadding).fillMaxSize(), contentType = contentType ) @@ -355,6 +352,7 @@ fun LayoutFromConfig( tablesVm: TablesViewModel, navController: NavHostController, extraAction: (() -> Unit)?, + chromeState: ChromeState, modifier: Modifier = Modifier, contentType: DaybookContentType ) { @@ -363,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)) } } @@ -440,20 +424,31 @@ 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 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, @@ -466,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) @@ -490,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) { @@ -500,6 +570,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 @@ -520,7 +593,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 @@ -533,89 +605,157 @@ fun SidebarContent(navController: NavHostController, modifier: Modifier = Modifi widthPx = it.width } ) { - if (isWide) { - var selectedSidebarPane by remember { mutableIntStateOf(0) } - - // 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 + Column( + modifier = Modifier.fillMaxSize() ) { - allSidebarFeatures.forEach { item -> - val featureRoute = getRouteForFeature(item) - val isSelected = featureRoute != null && featureRoute == currentRoute - NavigationRailItem( - selected = isSelected, - onClick = { - scope.launch { - if (item.enabled) { - item.onActivate() - } + 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 } - }, - enabled = item.enabled, - icon = { item.icon() }, - label = { item.labelContent?.invoke() ?: Text(item.label) }, - alwaysShowLabel = false - ) - } - } - 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) - ) + 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) + ) { + NavigationRail( + modifier = Modifier.fillMaxHeight().padding(top = 8.dp) + ) { + 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 + ) + } + 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 -> { - 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() + ) + } + } + } } } } - } - } else { - // Narrow mode: NavigationRail with features only - NavigationRail(modifier = Modifier.fillMaxHeight()) { - allSidebarFeatures.forEach { item -> - val featureRoute = getRouteForFeature(item) - val isSelected = featureRoute != null && featureRoute == currentRoute - - NavigationRailItem( - selected = isSelected, - onClick = { - scope.launch { - item.onActivate() + } 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 } - }, - enabled = item.enabled, - icon = { - item.icon() - }, - label = null // Rail mode: icon-only, no labels - ) + + 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 + ) + } } } } @@ -694,14 +834,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 @@ -748,6 +888,7 @@ interface DockedRegionScope { key: Any, modifier: Modifier = Modifier, regimes: List = listOf(PaneSizeRegime.Continuous()), + dividerContent: (@Composable () -> Unit)? = null, content: @Composable (() -> Unit) ) } @@ -768,6 +909,7 @@ fun DockableRegion( val key: Any, val modifier: Modifier, val regimes: List, + val dividerContent: (@Composable () -> Unit)?, val content: @Composable (() -> Unit) ) @@ -778,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)) } } @@ -807,10 +950,90 @@ 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 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) + } + + } + // 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 @@ -822,16 +1045,21 @@ 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 fun reconcile(keys: List, totalSizePx: Int) { + lastTotalSizePx = totalSizePx val totalWeight = keys.sumOf { getWeight(it).toDouble() }.toFloat() keys.forEach { key -> @@ -849,6 +1077,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 @@ -864,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] @@ -898,7 +1136,8 @@ 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 ) @@ -908,7 +1147,8 @@ fun DockableRegion( 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 ) @@ -920,7 +1160,8 @@ fun DockableRegion( } } 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( @@ -941,7 +1182,8 @@ 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 ) @@ -952,7 +1194,8 @@ fun DockableRegion( 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( @@ -973,7 +1216,8 @@ 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 ) @@ -984,7 +1228,8 @@ fun DockableRegion( 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( @@ -1002,7 +1247,7 @@ fun DockableRegion( // Both continuous: use virtual sizes else -> { - val targetWeightA = (virtualSizeDpA / totalSizeDp) * totalWeight + val targetWeightA = sizeDpToWeight(virtualSizeDpA, totalSizeDp, totalWeight) val newWeightA = targetWeightA .coerceAtLeast( @@ -1035,6 +1280,9 @@ fun DockableRegion( weightMap[k] = v } } + if (lastTotalSizePx > 0) { + rebalanceDiscreteWeights(paneRegimes.keys.toList(), lastTotalSizePx) + } } } @@ -1045,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] @@ -1071,8 +1320,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) } } @@ -1092,8 +1341,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) } } @@ -1118,8 +1367,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 @@ -1135,8 +1385,9 @@ fun DockableRegion( } 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 @@ -1156,8 +1407,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) @@ -1223,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) + 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 } - when (orientation) { - Orientation.Horizontal -> { - VerticalDivider( - modifier = Modifier.align(Alignment.Center), - color = color - ) + }, + onDragCancel = { + if (dragStarted) { + state.endDrag(currentKeys, index, totalSizePx) + dragStarted = false } - - Orientation.Vertical -> { - HorizontalDivider( - modifier = Modifier.align(Alignment.Center), - color = color - ) - } - } - } + }, + centerContent = scope.items[index].dividerContent + ) } } } 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..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 @@ -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 @@ -119,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()) { @@ -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( @@ -267,6 +282,7 @@ private fun FacetListItem( } } +@OptIn(ExperimentalMaterial3Api::class) @Composable private fun FacetHeader( descriptor: FacetViewDescriptor, @@ -349,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/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/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..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 @@ -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.DispatchUpdated( + FfiConverterString.read(buf), + FfiConverterTypeChangeHashSet.read(buf), + FfiConverterTypeSwitchEventOrigin.read(buf), ) - 2 -> DispatchEvent.DispatchDeleted( + 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 */ 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..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 @@ -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 */ 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..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 @@ -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 */ 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..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 @@ -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) + val gcx = withContext(Dispatchers.IO) { AppFfiCtx.init() } + return try { + block(gcx) } finally { - gcx.close() + 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 117a32bf..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 @@ -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 @@ -184,11 +186,20 @@ 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, permCtx: PermissionsContext?, - cameraPreviewFfi: CameraPreviewFfi, + cameraPreviewFfi: CameraPreviewFfi?, selectedWelcomeRepo: KnownRepoEntry?, cloneUiState: CloneUiState?, createRepoUiState: CreateRepoUiState?, @@ -333,19 +344,51 @@ 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" + val defaultParentResult = fetchDefaultParentDir() + if (defaultParentResult.isSuccess) { + val defaultParent = defaultParentResult.getOrThrow() + onCreateRepoUiStateChange( + CreateRepoUiState.Editing( + repoName = repoName, + parentPath = defaultParent, + isCreating = false + ) + ) + } else { + val error = defaultParentResult.exceptionOrNull() ?: error("unknown failure") + onCreateRepoUiStateChange( + CreateRepoUiState.Editing( + repoName = repoName, + parentPath = "", + isCreating = false, + errorMessage = "Failed loading default parent: ${describeThrowable(error)}" + ) + ) + } } + return@composable } CreateRepoScreen( @@ -382,24 +425,22 @@ fun WelcomeFlowNavHost( } ) - if (editState.parentPath.isBlank()) { - LaunchedEffect(Unit) { - try { - val defaultParent = withAppFfiCtx { gcx -> - gcx.defaultCloneParentDir().trim() - } - updateCreateState { current -> - if (!current.parentPath.isBlank()) { - current - } else { - current.copy( + if (editState.parentPath.isBlank() && !editState.isCreating) { + LaunchedEffect(editState.parentPath, editState.isCreating) { + val defaultParentResult = fetchDefaultParentDir() + if (defaultParentResult.isSuccess) { + val defaultParent = defaultParentResult.getOrThrow() + 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 + } else { + val error = defaultParentResult.exceptionOrNull() ?: error("unknown failure") updateCreateState { current -> current.copy( errorMessage = "Failed loading default parent: ${describeThrowable(error)}" @@ -574,13 +615,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,27 +1087,47 @@ 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 && !useNativePreviewQr) { + analyzer = withContext(Dispatchers.IO) { CameraQrAnalyzerFfi.load() } + } + } + val analyzerReady = analyzer + if (!useNativePreviewQr && 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) { - CameraQrOverlayBridge( - analyzer = analyzer, - onDetectedText = { rawText -> - uiScope.launch { - if (hasCompleted) return@launch - val candidate = rawText.trim() - if (!looksLikeUrl(candidate)) { - userVisibleError = "Detected QR is not a URL." - return@launch + remember(analyzerReady) { + 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) { @@ -1074,18 +1147,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(analyzer, frameBridge, previewBridge, useNativePreviewQr) { + androidx.compose.runtime.DisposableEffect(analyzerReady, frameBridge, previewBridge, useNativePreviewQr) { if (useNativePreviewQr) { previewBridge.start() } else { - frameBridge.start() + frameBridge?.start() } onDispose { previewBridge.stop() - frameBridge.stop() - analyzer.close() + frameBridge?.stop() + analyzerReady?.close() } } @@ -1104,7 +1182,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 @@ -1327,6 +1410,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..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 @@ -1,6 +1,12 @@ 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 @@ -8,8 +14,30 @@ 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}") + throw error + } +} fun main() = application { + installSignalHandler("INT") + installSignalHandler("TERM") + val windowState = rememberWindowState( // FIXME: niri/xwayland doesn't like the javafx resize-logic @@ -18,11 +46,37 @@ 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) } + .onFailure { error -> + println("[APP_SHUTDOWN] failed to remove shutdown hook err=${error.message}") + } + } + } + Window( - onCloseRequest = ::exitApplication, + onCloseRequest = { + println("[APP_SHUTDOWN] start: close requested, beginning graceful shutdown") + 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") + shutdownRequested = true + } + if (shutdownDone) break + delay(100) + } + } CompositionLocalProvider( LocalDensity provides Density( @@ -42,6 +96,15 @@ fun main() = application { ) { App( extraAction = { + }, + shutdownRequested = shutdownRequested, + onShutdownCompleted = { + shutdownDone = true + EventQueue.invokeLater { exitApplication() } + }, + autoShutdownOnDispose = false, + onExitRequest = { + signalShutdownRequested.set(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/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/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/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 2232fdb3..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 { @@ -132,8 +133,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 +145,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 +154,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 +176,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 +195,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(); @@ -218,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")?; @@ -232,6 +250,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 +258,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 +274,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 +283,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 +309,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 +339,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 +359,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 +368,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 +380,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 +407,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 +424,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 +442,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 +476,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 +510,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 +533,7 @@ impl RepoCtx { .stop() .await?; blobs_repo.shutdown().await?; + info!("repo init dance: completed"); Ok(()) } } @@ -665,11 +713,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 +729,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..ee4072be 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,49 @@ 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 + .wrap_err_with(|| { + format!( + "failed to add_update for {plug_id}/{init_key} {stage} task_id={task_id}" + ) + }) + } } 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::*; diff --git a/x/build-a-dayb.ts b/x/build-a-dayb.ts index 86094fbe..b66c3ae9 100755 --- a/x/build-a-dayb.ts +++ b/x/build-a-dayb.ts @@ -72,6 +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 distDir = ortRootDir.join( "dist", ortSourceTag, @@ -87,6 +91,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 +112,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 +142,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); }