diff --git a/sample/shared/src/commonMain/composeResources/drawable/bookmark.png b/sample/shared/src/commonMain/composeResources/drawable/bookmark.png new file mode 100644 index 00000000..3f78d40b Binary files /dev/null and b/sample/shared/src/commonMain/composeResources/drawable/bookmark.png differ diff --git a/sample/shared/src/commonMain/kotlin/io/github/vinceglb/filekit/sample/shared/navigation/AppNavigation.kt b/sample/shared/src/commonMain/kotlin/io/github/vinceglb/filekit/sample/shared/navigation/AppNavigation.kt index 7cc289b2..d9620b03 100644 --- a/sample/shared/src/commonMain/kotlin/io/github/vinceglb/filekit/sample/shared/navigation/AppNavigation.kt +++ b/sample/shared/src/commonMain/kotlin/io/github/vinceglb/filekit/sample/shared/navigation/AppNavigation.kt @@ -13,6 +13,7 @@ import androidx.navigation3.runtime.rememberSaveableStateHolderNavEntryDecorator import androidx.navigation3.ui.NavDisplay import androidx.savedstate.serialization.SavedStateConfiguration import io.github.vinceglb.filekit.PlatformFile +import io.github.vinceglb.filekit.sample.shared.ui.screens.bookmarks.BookmarksRoute import io.github.vinceglb.filekit.sample.shared.ui.screens.camerapicker.CameraPickerRoute import io.github.vinceglb.filekit.sample.shared.ui.screens.debug.DebugRoute import io.github.vinceglb.filekit.sample.shared.ui.screens.directorypicker.DirectoryPickerRoute @@ -35,6 +36,9 @@ private data object GalleryPicker : NavKey @Serializable private data object CameraPicker : NavKey +@Serializable +private data object Bookmarks : NavKey + @Serializable private data object Debug : NavKey @@ -65,6 +69,7 @@ internal fun AppNavigation( serializersModule = SerializersModule { polymorphic(NavKey::class) { subclass(CameraPicker::class, CameraPicker.serializer()) + subclass(Bookmarks::class, Bookmarks.serializer()) subclass(Debug::class, Debug.serializer()) subclass(DirectoryPicker::class, DirectoryPicker.serializer()) subclass(FileDetails::class, FileDetails.serializer()) @@ -94,6 +99,7 @@ internal fun AppNavigation( onFilePickerClick = { backStack.add(FilePicker) }, onDirectoryPickerClick = { backStack.add(DirectoryPicker) }, onCameraPickerClick = { backStack.add(CameraPicker) }, + onBookmarksClick = { backStack.add(Bookmarks) }, onFileSaverClick = { backStack.add(FileSaver) }, onShareFileClick = { backStack.add(ShareFile) }, onDebugClick = { backStack.add(Debug) }, @@ -123,6 +129,14 @@ internal fun AppNavigation( }, ) } + entry { + BookmarksRoute( + onNavigateBack = { backStack.removeLastOrNull() }, + onDisplayFileDetails = { file -> + backStack.add(FileDetails(file)) + }, + ) + } entry { DebugRoute( onNavigateBack = { backStack.removeLastOrNull() }, diff --git a/sample/shared/src/commonMain/kotlin/io/github/vinceglb/filekit/sample/shared/ui/screens/bookmarks/BookmarksScreen.kt b/sample/shared/src/commonMain/kotlin/io/github/vinceglb/filekit/sample/shared/ui/screens/bookmarks/BookmarksScreen.kt new file mode 100644 index 00000000..021814fb --- /dev/null +++ b/sample/shared/src/commonMain/kotlin/io/github/vinceglb/filekit/sample/shared/ui/screens/bookmarks/BookmarksScreen.kt @@ -0,0 +1,247 @@ +package io.github.vinceglb.filekit.sample.shared.ui.screens.bookmarks + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.sizeIn +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import io.github.vinceglb.filekit.PlatformFile +import io.github.vinceglb.filekit.dialogs.compose.rememberFilePickerLauncher +import io.github.vinceglb.filekit.name +import io.github.vinceglb.filekit.sample.shared.ui.components.AppDottedBorderCard +import io.github.vinceglb.filekit.sample.shared.ui.components.AppPickerResultsCard +import io.github.vinceglb.filekit.sample.shared.ui.components.AppPickerSelectionButton +import io.github.vinceglb.filekit.sample.shared.ui.components.AppPickerSupportCard +import io.github.vinceglb.filekit.sample.shared.ui.components.AppPickerTopBar +import io.github.vinceglb.filekit.sample.shared.ui.components.AppScreenHeader +import io.github.vinceglb.filekit.sample.shared.ui.components.AppScreenHeaderButtonState +import io.github.vinceglb.filekit.sample.shared.ui.icons.BookOpenText +import io.github.vinceglb.filekit.sample.shared.ui.icons.File +import io.github.vinceglb.filekit.sample.shared.ui.icons.Folder +import io.github.vinceglb.filekit.sample.shared.ui.icons.LucideIcons +import io.github.vinceglb.filekit.sample.shared.ui.screens.directorypicker.rememberDirectoryPickerLauncher +import io.github.vinceglb.filekit.sample.shared.ui.theme.AppMaxWidth +import io.github.vinceglb.filekit.sample.shared.ui.theme.AppTheme +import io.github.vinceglb.filekit.sample.shared.util.AppUrl +import io.github.vinceglb.filekit.sample.shared.util.BookmarkKind +import io.github.vinceglb.filekit.sample.shared.util.BookmarkStorage +import io.github.vinceglb.filekit.sample.shared.util.openUrlInBrowser +import io.github.vinceglb.filekit.sample.shared.util.plus +import kotlinx.coroutines.launch + +@Composable +internal fun BookmarksRoute( + onNavigateBack: () -> Unit, + onDisplayFileDetails: (file: PlatformFile) -> Unit, +) { + BookmarksScreen( + onNavigateBack = onNavigateBack, + onDisplayFileDetails = onDisplayFileDetails, + ) +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun BookmarksScreen( + onNavigateBack: () -> Unit, + onDisplayFileDetails: (file: PlatformFile) -> Unit, +) { + val storage = remember { BookmarkStorage() } + val scope = rememberCoroutineScope() + + var buttonState by remember { mutableStateOf(AppScreenHeaderButtonState.Enabled) } + var bookmarkedFile by remember { mutableStateOf(null) } + var bookmarkedDirectory by remember { mutableStateOf(null) } + + val filePickerLauncher = rememberFilePickerLauncher { file -> + scope.launch { + if (file != null) { + storage.save(BookmarkKind.File, file) + bookmarkedFile = file + } + buttonState = AppScreenHeaderButtonState.Enabled + } + } + val directoryPickerLauncher = rememberDirectoryPickerLauncher( + directory = bookmarkedDirectory, + ) { directory -> + scope.launch { + if (directory != null) { + storage.save(BookmarkKind.Directory, directory) + bookmarkedDirectory = directory + } + buttonState = AppScreenHeaderButtonState.Enabled + } + } + + LaunchedEffect(storage) { + bookmarkedFile = storage.load(BookmarkKind.File) + bookmarkedDirectory = storage.load(BookmarkKind.Directory) + } + + val isBookmarkSupported = storage.isSupported + val isDirectoryPickerSupported = isBookmarkSupported && directoryPickerLauncher.isSupported + val primaryButtonText = if (isBookmarkSupported) "Pick File" else "Bookmarks Unavailable" + + fun openFilePicker() { + if (!isBookmarkSupported) { + return + } + buttonState = AppScreenHeaderButtonState.Loading + filePickerLauncher.launch() + } + + fun openDirectoryPicker() { + if (!isDirectoryPickerSupported) { + return + } + buttonState = AppScreenHeaderButtonState.Loading + directoryPickerLauncher.launch() + } + + fun clearBookmark(kind: BookmarkKind) { + scope.launch { + storage.save(kind, null) + when (kind) { + BookmarkKind.File -> bookmarkedFile = null + BookmarkKind.Directory -> bookmarkedDirectory = null + } + } + } + + val bookmarkedItems = listOfNotNull(bookmarkedFile, bookmarkedDirectory) + + Scaffold( + topBar = { + AppPickerTopBar( + onNavigateBack = onNavigateBack, + onOpenDocumentation = { AppUrl("https://filekit.mintlify.app/core/bookmark-data").openUrlInBrowser() }, + ) + }, + ) { contentPadding -> + LazyColumn( + contentPadding = contentPadding + PaddingValues(all = 16.dp), + verticalArrangement = Arrangement.spacedBy(16.dp), + horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier.fillMaxWidth(), + ) { + item { + AppScreenHeader( + icon = LucideIcons.BookOpenText, + title = "Bookmarks", + subtitle = "Persist file and folder access using bookmark data", + documentationUrl = "https://filekit.mintlify.app/core/bookmark-data", + primaryButtonText = primaryButtonText, + primaryButtonEnabled = isBookmarkSupported, + primaryButtonState = buttonState, + onPrimaryButtonClick = ::openFilePicker, + modifier = Modifier.sizeIn(maxWidth = AppMaxWidth), + ) + } + + item { + BookmarkSettingsCard( + bookmarkedFileName = bookmarkedFile?.name, + bookmarkedDirectoryName = bookmarkedDirectory?.name, + isFilePickerSupported = isBookmarkSupported, + isDirectoryPickerSupported = isDirectoryPickerSupported, + onPickFile = ::openFilePicker, + onPickDirectory = ::openDirectoryPicker, + onClearFile = { clearBookmark(BookmarkKind.File) }, + onClearDirectory = { clearBookmark(BookmarkKind.Directory) }, + modifier = Modifier.sizeIn(maxWidth = AppMaxWidth), + ) + } + + if (!isBookmarkSupported) { + item { + AppPickerSupportCard( + text = "Bookmark data is available on Android, iOS, and desktop targets.", + icon = LucideIcons.BookOpenText, + modifier = Modifier.sizeIn(maxWidth = AppMaxWidth), + ) + } + } + + item { + AppPickerResultsCard( + files = bookmarkedItems, + emptyText = "No bookmarks saved yet", + emptyIcon = LucideIcons.BookOpenText, + onFileClick = onDisplayFileDetails, + modifier = Modifier.sizeIn(maxWidth = AppMaxWidth), + ) + } + } + } +} + +@Composable +private fun BookmarkSettingsCard( + bookmarkedFileName: String?, + bookmarkedDirectoryName: String?, + isFilePickerSupported: Boolean, + isDirectoryPickerSupported: Boolean, + onPickFile: () -> Unit, + onPickDirectory: () -> Unit, + onClearFile: () -> Unit, + onClearDirectory: () -> Unit, + modifier: Modifier = Modifier, +) { + AppDottedBorderCard(modifier = modifier) { + Column(verticalArrangement = Arrangement.spacedBy(16.dp)) { + AppPickerSelectionButton( + label = "Bookmarked File", + value = bookmarkedFileName, + placeholder = "No file bookmarked", + icon = LucideIcons.File, + enabled = isFilePickerSupported, + onClick = onPickFile, + onClear = onClearFile, + ) + + AppPickerSelectionButton( + label = "Bookmarked Folder", + value = bookmarkedDirectoryName, + placeholder = "No folder bookmarked", + icon = LucideIcons.Folder, + enabled = isDirectoryPickerSupported, + onClick = onPickDirectory, + onClear = onClearDirectory, + ) + + Text( + text = "Tip: Bookmarks are stored in the app files directory and restored on launch.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.outline, + ) + } + } +} + +@Preview +@Composable +private fun BookmarksScreenPreview() { + AppTheme { + BookmarksScreen( + onNavigateBack = {}, + onDisplayFileDetails = {}, + ) + } +} diff --git a/sample/shared/src/commonMain/kotlin/io/github/vinceglb/filekit/sample/shared/ui/screens/home/HomeScreen.kt b/sample/shared/src/commonMain/kotlin/io/github/vinceglb/filekit/sample/shared/ui/screens/home/HomeScreen.kt index 9f3f16c0..342745ce 100644 --- a/sample/shared/src/commonMain/kotlin/io/github/vinceglb/filekit/sample/shared/ui/screens/home/HomeScreen.kt +++ b/sample/shared/src/commonMain/kotlin/io/github/vinceglb/filekit/sample/shared/ui/screens/home/HomeScreen.kt @@ -32,6 +32,7 @@ import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import filekit.sample.shared.generated.resources.Res +import filekit.sample.shared.generated.resources.bookmark import filekit.sample.shared.generated.resources.camera_picker import filekit.sample.shared.generated.resources.directory_picker import filekit.sample.shared.generated.resources.file_picker @@ -56,6 +57,7 @@ internal fun HomeRoute( onGalleryPickerClick: () -> Unit, onDirectoryPickerClick: () -> Unit, onCameraPickerClick: () -> Unit, + onBookmarksClick: () -> Unit, onFileSaverClick: () -> Unit, onShareFileClick: () -> Unit, onDebugClick: () -> Unit, @@ -65,6 +67,7 @@ internal fun HomeRoute( onGalleryPickerClick = onGalleryPickerClick, onDirectoryPickerClick = onDirectoryPickerClick, onCameraPickerClick = onCameraPickerClick, + onBookmarksClick = onBookmarksClick, onFileSaverClick = onFileSaverClick, onShareFileClick = onShareFileClick, onDebugClick = onDebugClick, @@ -77,6 +80,7 @@ private fun HomeScreen( onGalleryPickerClick: () -> Unit, onDirectoryPickerClick: () -> Unit, onCameraPickerClick: () -> Unit, + onBookmarksClick: () -> Unit, onFileSaverClick: () -> Unit, onShareFileClick: () -> Unit, onDebugClick: () -> Unit, @@ -158,6 +162,14 @@ private fun HomeScreen( ) } + item { + HomeEntry( + label = "Bookmarks", + image = Res.drawable.bookmark, + onClick = onBookmarksClick, + ) + } + item { HomeEntry( label = "Share File", @@ -167,7 +179,9 @@ private fun HomeScreen( } item { - HomeDebugEntry( + HomeIconEntry( + label = "Debug", + icon = LucideIcons.MessageCircleCode, onClick = onDebugClick, ) } @@ -221,7 +235,9 @@ private fun HomeEntry( } @Composable -private fun HomeDebugEntry( +private fun HomeIconEntry( + label: String, + icon: androidx.compose.ui.graphics.vector.ImageVector, onClick: () -> Unit, modifier: Modifier = Modifier, ) { @@ -249,7 +265,7 @@ private fun HomeDebugEntry( contentAlignment = Alignment.Center, ) { Icon( - imageVector = LucideIcons.MessageCircleCode, + imageVector = icon, contentDescription = null, tint = MaterialTheme.colorScheme.onPrimaryContainer, modifier = Modifier.size(40.dp), @@ -259,7 +275,7 @@ private fun HomeDebugEntry( } Text( - text = "Debug", + text = label, fontWeight = FontWeight.Medium, fontSize = 15.sp, letterSpacing = (-0.45).sp, @@ -277,6 +293,7 @@ private fun HomeScreenPreview() { onFilePickerClick = {}, onDirectoryPickerClick = {}, onCameraPickerClick = {}, + onBookmarksClick = {}, onFileSaverClick = {}, onShareFileClick = {}, onDebugClick = {}, diff --git a/sample/shared/src/commonMain/kotlin/io/github/vinceglb/filekit/sample/shared/util/BookmarkStorage.kt b/sample/shared/src/commonMain/kotlin/io/github/vinceglb/filekit/sample/shared/util/BookmarkStorage.kt new file mode 100644 index 00000000..c6b54f9a --- /dev/null +++ b/sample/shared/src/commonMain/kotlin/io/github/vinceglb/filekit/sample/shared/util/BookmarkStorage.kt @@ -0,0 +1,16 @@ +package io.github.vinceglb.filekit.sample.shared.util + +import io.github.vinceglb.filekit.PlatformFile + +internal enum class BookmarkKind { + File, + Directory, +} + +internal expect class BookmarkStorage() { + val isSupported: Boolean + + suspend fun load(kind: BookmarkKind): PlatformFile? + + suspend fun save(kind: BookmarkKind, file: PlatformFile?) +} diff --git a/sample/shared/src/nonWebMain/kotlin/io/github/vinceglb/filekit/sample/shared/util/BookmarkStorage.nonWeb.kt b/sample/shared/src/nonWebMain/kotlin/io/github/vinceglb/filekit/sample/shared/util/BookmarkStorage.nonWeb.kt new file mode 100644 index 00000000..d625e13f --- /dev/null +++ b/sample/shared/src/nonWebMain/kotlin/io/github/vinceglb/filekit/sample/shared/util/BookmarkStorage.nonWeb.kt @@ -0,0 +1,70 @@ +package io.github.vinceglb.filekit.sample.shared.util + +import io.github.vinceglb.filekit.FileKit +import io.github.vinceglb.filekit.PlatformFile +import io.github.vinceglb.filekit.bookmarkData +import io.github.vinceglb.filekit.createDirectories +import io.github.vinceglb.filekit.delete +import io.github.vinceglb.filekit.div +import io.github.vinceglb.filekit.exists +import io.github.vinceglb.filekit.filesDir +import io.github.vinceglb.filekit.fromBookmarkData +import io.github.vinceglb.filekit.readBytes +import io.github.vinceglb.filekit.releaseBookmark +import io.github.vinceglb.filekit.write + +internal actual class BookmarkStorage actual constructor() { + actual val isSupported: Boolean = true + + private val bookmarksDirectory = FileKit.filesDir / "sample-bookmarks" + private val fileBookmark = bookmarksDirectory / "bookmarked-file.bin" + private val directoryBookmark = bookmarksDirectory / "bookmarked-directory.bin" + + actual suspend fun load(kind: BookmarkKind): PlatformFile? { + val bookmarkFile = resolveBookmarkFile(kind) + if (!bookmarkFile.exists()) { + return null + } + + return try { + val bytes = bookmarkFile.readBytes() + PlatformFile.fromBookmarkData(bytes) + } catch (_: Throwable) { + bookmarkFile.delete(mustExist = false) + null + } + } + + actual suspend fun save(kind: BookmarkKind, file: PlatformFile?) { + val bookmarkFile = resolveBookmarkFile(kind) + if (file == null) { + clearBookmark(bookmarkFile) + return + } + + bookmarksDirectory.createDirectories() + val bookmark = file.bookmarkData() + bookmarkFile write bookmark.bytes + } + + private suspend fun clearBookmark(bookmarkFile: PlatformFile) { + if (!bookmarkFile.exists()) { + return + } + + try { + val bytes = bookmarkFile.readBytes() + val bookmarkedFile = PlatformFile.fromBookmarkData(bytes) + bookmarkedFile.releaseBookmark() + } catch (_: Throwable) { + // Ignore failures from stale bookmarks. + } + + bookmarkFile.delete(mustExist = false) + } + + private fun resolveBookmarkFile(kind: BookmarkKind): PlatformFile = when (kind) { + BookmarkKind.File -> fileBookmark + BookmarkKind.Directory -> directoryBookmark + } +} diff --git a/sample/shared/src/webMain/kotlin/io/github/vinceglb/filekit/sample/shared/util/BookmarkStorage.web.kt b/sample/shared/src/webMain/kotlin/io/github/vinceglb/filekit/sample/shared/util/BookmarkStorage.web.kt new file mode 100644 index 00000000..b626e6fb --- /dev/null +++ b/sample/shared/src/webMain/kotlin/io/github/vinceglb/filekit/sample/shared/util/BookmarkStorage.web.kt @@ -0,0 +1,13 @@ +package io.github.vinceglb.filekit.sample.shared.util + +import io.github.vinceglb.filekit.PlatformFile + +internal actual class BookmarkStorage actual constructor() { + actual val isSupported: Boolean = false + + actual suspend fun load(kind: BookmarkKind): PlatformFile? = null + + actual suspend fun save(kind: BookmarkKind, file: PlatformFile?) { + // Bookmark data is not supported on web targets. + } +}