diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 4962161b..bb813b78 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -13,6 +13,9 @@ accompanist = "0.20.1" kamel = "0.3.0" materialDialogs = "0.6.4" +# Android +activityCompose = "1.3.1" + # Swing darklaf = "2.7.3" @@ -61,6 +64,9 @@ accompanistFlowLayout = { module = "ca.gosyer:accompanist-flowlayout", version.r kamel = { module = "com.alialbaali.kamel:kamel-image", version.ref = "kamel" } materialDialogsCore = { module = "ca.gosyer:compose-material-dialogs-core", version.ref = "materialDialogs" } +# Android +activityCompose = { module = "androidx.activity:activity-compose", version.ref = "activityCompose" } + # Swing darklaf = { module = "com.github.weisj:darklaf-core", version.ref = "darklaf" } @@ -99,4 +105,4 @@ desugarJdkLibs = { module = "com.android.tools:desugar_jdk_libs", version.ref = # Localization mokoCore = { module = "dev.icerock.moko:resources", version.ref = "moko" } -mokoCompose= { module = "dev.icerock.moko:resources-compose", version.ref = "moko" } \ No newline at end of file +mokoCompose = { module = "dev.icerock.moko:resources-compose", version.ref = "moko" } \ No newline at end of file diff --git a/presentation/build.gradle.kts b/presentation/build.gradle.kts index 7c43216a..a5b12650 100644 --- a/presentation/build.gradle.kts +++ b/presentation/build.gradle.kts @@ -82,6 +82,7 @@ kotlin { kotlin.srcDir("build/generated/ksp/androidRelease/kotlin") dependencies { api(kotlin("stdlib-jdk8")) + api(libs.activityCompose) } } val androidTest by getting { diff --git a/presentation/src/androidMain/kotlin/ca/gosyer/ui/base/file/AndroidFileChooser.kt b/presentation/src/androidMain/kotlin/ca/gosyer/ui/base/file/AndroidFileChooser.kt new file mode 100644 index 00000000..a19e5138 --- /dev/null +++ b/presentation/src/androidMain/kotlin/ca/gosyer/ui/base/file/AndroidFileChooser.kt @@ -0,0 +1,33 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + */ + +package ca.gosyer.ui.base.file + +import android.net.Uri +import android.webkit.MimeTypeMap +import androidx.activity.compose.ManagedActivityResultLauncher +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.core.net.toFile +import okio.Path +import okio.Path.Companion.toOkioPath + +actual class FileChooser(private val resultLauncher: ManagedActivityResultLauncher) { + actual fun launch(extension: String) { + resultLauncher.launch(MimeTypeMap.getSingleton().getMimeTypeFromExtension(extension)) + } +} + +@Composable +actual fun rememberFileChooser(onFileFound: (Path) -> Unit): FileChooser { + val result = rememberLauncherForActivityResult(ActivityResultContracts.GetContent()) { + it?.toFile()?.toOkioPath()?.let(onFileFound) + } + + return remember { FileChooser(result) } +} \ No newline at end of file diff --git a/presentation/src/androidMain/kotlin/ca/gosyer/ui/base/file/AndroidFileSaver.kt b/presentation/src/androidMain/kotlin/ca/gosyer/ui/base/file/AndroidFileSaver.kt new file mode 100644 index 00000000..f9345f8f --- /dev/null +++ b/presentation/src/androidMain/kotlin/ca/gosyer/ui/base/file/AndroidFileSaver.kt @@ -0,0 +1,42 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + */ + +package ca.gosyer.ui.base.file + +import android.net.Uri +import androidx.activity.compose.ManagedActivityResultLauncher +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.core.net.toFile +import okio.Path +import okio.Path.Companion.toOkioPath + +actual class FileSaver( + private val resultLauncher: ManagedActivityResultLauncher, +) { + actual fun save(name: String) { + resultLauncher.launch(name) + } +} + +@Composable +actual fun rememberFileSaver( + onFileSelected: (Path) -> Unit, + onCancel: () -> Unit, + onError: () -> Unit, +): FileSaver { + val result = rememberLauncherForActivityResult(ActivityResultContracts.CreateDocument()) { + if (it != null) { + it.toFile().toOkioPath().let(onFileSelected) + } else { + onCancel() + } + } + + return remember { FileSaver(result) } +} \ No newline at end of file diff --git a/presentation/src/androidMain/kotlin/ca/gosyer/ui/categories/OpenCategories.kt b/presentation/src/androidMain/kotlin/ca/gosyer/ui/categories/OpenCategories.kt new file mode 100644 index 00000000..540f3fef --- /dev/null +++ b/presentation/src/androidMain/kotlin/ca/gosyer/ui/categories/OpenCategories.kt @@ -0,0 +1,13 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + */ + +package ca.gosyer.ui.categories + +import cafe.adriel.voyager.navigator.Navigator + +actual fun openCategoriesMenu(notifyFinished: () -> Unit, navigator: Navigator) { + navigator push CategoriesScreen(notifyFinished) +} \ No newline at end of file diff --git a/presentation/src/desktopMain/kotlin/ca/gosyer/ui/base/file/DesktopFileChooser.kt b/presentation/src/desktopMain/kotlin/ca/gosyer/ui/base/file/DesktopFileChooser.kt new file mode 100644 index 00000000..49151ce5 --- /dev/null +++ b/presentation/src/desktopMain/kotlin/ca/gosyer/ui/base/file/DesktopFileChooser.kt @@ -0,0 +1,41 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + */ + +package ca.gosyer.ui.base.file + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import ca.gosyer.core.lang.launchDefault +import kotlinx.coroutines.CoroutineScope +import okio.Path +import okio.Path.Companion.toOkioPath +import javax.swing.JFileChooser +import javax.swing.filechooser.FileNameExtensionFilter + +actual class FileChooser(private val onFileFound: (Path) -> Unit, private val scope: CoroutineScope) { + private val fileChooser = JFileChooser() + .apply { + val details = actionMap.get("viewTypeDetails") + details?.actionPerformed(null) + + } + + actual fun launch(extension: String) { + scope.launchDefault { + fileChooser.fileFilter = FileNameExtensionFilter("$extension file", extension) + when (fileChooser.showOpenDialog(null)) { + JFileChooser.APPROVE_OPTION -> onFileFound(fileChooser.selectedFile.toOkioPath()) + } + } + } +} + +@Composable +actual fun rememberFileChooser(onFileFound: (Path) -> Unit): FileChooser { + val coroutineScope = rememberCoroutineScope() + return remember { FileChooser(onFileFound, coroutineScope) } +} \ No newline at end of file diff --git a/presentation/src/desktopMain/kotlin/ca/gosyer/ui/base/file/DesktopFileSaver.kt b/presentation/src/desktopMain/kotlin/ca/gosyer/ui/base/file/DesktopFileSaver.kt new file mode 100644 index 00000000..632c9c5a --- /dev/null +++ b/presentation/src/desktopMain/kotlin/ca/gosyer/ui/base/file/DesktopFileSaver.kt @@ -0,0 +1,50 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + */ + +package ca.gosyer.ui.base.file + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import ca.gosyer.core.lang.launchDefault +import kotlinx.coroutines.CoroutineScope +import okio.Path +import okio.Path.Companion.toOkioPath +import javax.swing.JFileChooser + +actual class FileSaver( + private val onFileSelected: (Path) -> Unit, + private val onCancel: () -> Unit, + private val onError: () -> Unit, + private val scope: CoroutineScope +) { + private val fileChooser = JFileChooser() + .apply { + val details = actionMap.get("viewTypeDetails") + details?.actionPerformed(null) + } + + actual fun save(name: String) { + scope.launchDefault { + fileChooser.selectedFile = fileChooser.currentDirectory.resolve(name) + when (fileChooser.showSaveDialog(null)) { + JFileChooser.APPROVE_OPTION -> onFileSelected(fileChooser.selectedFile.toOkioPath()) + JFileChooser.CANCEL_OPTION -> onCancel() + JFileChooser.ERROR_OPTION -> onError() + } + } + } +} + +@Composable +actual fun rememberFileSaver( + onFileSelected: (Path) -> Unit, + onCancel: () -> Unit, + onError: () -> Unit, +): FileSaver { + val coroutineScope = rememberCoroutineScope() + return remember { FileSaver(onFileSelected, onCancel, onError, coroutineScope) } +} \ No newline at end of file diff --git a/presentation/src/desktopMain/kotlin/ca/gosyer/ui/categories/CategoriesWindow.kt b/presentation/src/desktopMain/kotlin/ca/gosyer/ui/categories/CategoriesWindow.kt index 4c2adcd8..62e5d48d 100644 --- a/presentation/src/desktopMain/kotlin/ca/gosyer/ui/categories/CategoriesWindow.kt +++ b/presentation/src/desktopMain/kotlin/ca/gosyer/ui/categories/CategoriesWindow.kt @@ -16,7 +16,7 @@ import cafe.adriel.voyager.navigator.Navigator import kotlinx.coroutines.DelicateCoroutinesApi @OptIn(DelicateCoroutinesApi::class) -fun openCategoriesMenu(notifyFinished: (() -> Unit)? = null) { +actual fun openCategoriesMenu(notifyFinished: () -> Unit, navigator: Navigator) { launchApplication { CompositionLocalProvider(*remember { AppComponent.getInstance().uiComponent.getHooks() }) { ThemedWindow(::exitApplication, title = "${BuildKonfig.NAME} - Categories") { diff --git a/presentation/src/jvmMain/kotlin/ca/gosyer/ui/base/file/FileChooser.kt b/presentation/src/jvmMain/kotlin/ca/gosyer/ui/base/file/FileChooser.kt new file mode 100644 index 00000000..f2c80527 --- /dev/null +++ b/presentation/src/jvmMain/kotlin/ca/gosyer/ui/base/file/FileChooser.kt @@ -0,0 +1,17 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + */ + +package ca.gosyer.ui.base.file + +import androidx.compose.runtime.Composable +import okio.Path + +expect class FileChooser { + fun launch(extension: String) +} + +@Composable +expect fun rememberFileChooser(onFileFound: (Path) -> Unit): FileChooser \ No newline at end of file diff --git a/presentation/src/jvmMain/kotlin/ca/gosyer/ui/base/file/FileSaver.kt b/presentation/src/jvmMain/kotlin/ca/gosyer/ui/base/file/FileSaver.kt new file mode 100644 index 00000000..49adff92 --- /dev/null +++ b/presentation/src/jvmMain/kotlin/ca/gosyer/ui/base/file/FileSaver.kt @@ -0,0 +1,21 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + */ + +package ca.gosyer.ui.base.file + +import androidx.compose.runtime.Composable +import okio.Path + +expect class FileSaver { + fun save(name: String) +} + +@Composable +expect fun rememberFileSaver( + onFileSelected: (Path) -> Unit, + onCancel: () -> Unit = {}, + onError: () -> Unit = {}, +): FileSaver \ No newline at end of file diff --git a/presentation/src/jvmMain/kotlin/ca/gosyer/ui/categories/CategoriesScreen.kt b/presentation/src/jvmMain/kotlin/ca/gosyer/ui/categories/CategoriesScreen.kt index b51c9f2e..8ae50917 100644 --- a/presentation/src/jvmMain/kotlin/ca/gosyer/ui/categories/CategoriesScreen.kt +++ b/presentation/src/jvmMain/kotlin/ca/gosyer/ui/categories/CategoriesScreen.kt @@ -13,6 +13,9 @@ import ca.gosyer.uicore.vm.viewModel import cafe.adriel.voyager.core.screen.Screen import cafe.adriel.voyager.core.screen.ScreenKey import cafe.adriel.voyager.core.screen.uniqueScreenKey +import cafe.adriel.voyager.navigator.Navigator + +expect fun openCategoriesMenu(notifyFinished: () -> Unit, navigator: Navigator) class CategoriesScreen( @Transient diff --git a/presentation/src/jvmMain/kotlin/ca/gosyer/ui/settings/SettingsBackupScreen.kt b/presentation/src/jvmMain/kotlin/ca/gosyer/ui/settings/SettingsBackupScreen.kt index a2f4654a..ef422e7f 100644 --- a/presentation/src/jvmMain/kotlin/ca/gosyer/ui/settings/SettingsBackupScreen.kt +++ b/presentation/src/jvmMain/kotlin/ca/gosyer/ui/settings/SettingsBackupScreen.kt @@ -41,10 +41,10 @@ import ca.gosyer.i18n.MR import ca.gosyer.ui.base.components.VerticalScrollbar import ca.gosyer.ui.base.components.rememberScrollbarAdapter import ca.gosyer.ui.base.dialog.getMaterialDialogProperties +import ca.gosyer.ui.base.file.rememberFileChooser +import ca.gosyer.ui.base.file.rememberFileSaver import ca.gosyer.ui.base.navigation.Toolbar import ca.gosyer.ui.base.prefs.PreferenceRow -import ca.gosyer.ui.util.system.filePicker -import ca.gosyer.ui.util.system.fileSaver import ca.gosyer.uicore.resources.stringResource import ca.gosyer.uicore.vm.ContextWrapper import ca.gosyer.uicore.vm.ViewModel @@ -66,6 +66,8 @@ import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.launch +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock import me.tatarka.inject.annotations.Inject import okio.FileSystem import okio.Path @@ -90,7 +92,8 @@ class SettingsBackupScreen : Screen { restoreFile = vm::restoreFile, restoreBackup = vm::restoreBackup, stopRestore = vm::stopRestore, - exportBackup = vm::exportBackup + exportBackup = vm::exportBackup, + exportBackupFileFound = vm::exportBackupFileFound ) } } @@ -114,13 +117,13 @@ class SettingsBackupViewModel @Inject constructor( val creatingProgress = _creatingProgress.asStateFlow() private val _creatingStatus = MutableStateFlow(Status.Nothing) internal val creatingStatus = _creatingStatus.asStateFlow() - private val _createFlow = MutableSharedFlow Unit>>() + private val _createFlow = MutableSharedFlow() val createFlow = _createFlow.asSharedFlow() - fun restoreFile(file: Path?) { + fun restoreFile(file: Path) { scope.launch { - if (file == null || !FileSystem.SYSTEM.exists(file)) { - info { "Invalid file ${file?.toString()}" } + if (!FileSystem.SYSTEM.exists(file)) { + info { "Invalid file ${file.toString()}" } _restoreStatus.value = Status.Error _restoring.value = false } else { @@ -167,6 +170,9 @@ class SettingsBackupViewModel @Inject constructor( _restoring.value = false } + private val tempFile = MutableStateFlow(null) + private val mutex = Mutex() + fun exportBackup() { scope.launch { _creatingStatus.value = Status.Nothing @@ -181,31 +187,56 @@ class SettingsBackupViewModel @Inject constructor( } catch (e: Exception) { info(e) { "Error exporting backup" } _creatingStatus.value = Status.Error + _creating.value = false e.throwIfCancellation() null } _creatingProgress.value = 1.0F if (backup != null && backup.status.isSuccess()) { - _createFlow.emit( - (backup.headers["content-disposition"]?.substringAfter("filename=")?.trim('"') ?: "backup") to { - scope.launch { - try { - backup.content.toInputStream() - .source() - .copyTo( - FileSystem.SYSTEM.sink(it).buffer() - ) - _creatingStatus.value = Status.Success - } catch (e: Exception) { - e.throwIfCancellation() - error(e) { "Error creating backup" } - _creatingStatus.value = Status.Error - } finally { - _creating.value = false - } + val filename = backup.headers["content-disposition"]?.substringAfter("filename=")?.trim('"') ?: "backup" + tempFile.value = FileSystem.SYSTEM_TEMPORARY_DIRECTORY.resolve(filename).also { + launch { + try { + backup.content.toInputStream() + .source() + .copyTo( + FileSystem.SYSTEM.sink(it).buffer() + ) + } catch (e: Exception) { + e.throwIfCancellation() + error(e) { "Error creating backup" } + _creatingStatus.value = Status.Error + _creating.value = false + } finally { + mutex.unlock() } } - ) + } + mutex.tryLock() + _createFlow.emit(filename) + } + } + } + + fun exportBackupFileFound(backupPath: Path) { + scope.launch { + mutex.withLock { + val tempFile = tempFile.value + if (_creating.value && tempFile != null) { + try { + FileSystem.SYSTEM.atomicMove(tempFile, backupPath) + _creatingStatus.value = Status.Success + } catch (e: Exception) { + e.throwIfCancellation() + error(e) { "Error moving created backup" } + _creatingStatus.value = Status.Error + } finally { + _creating.value = false + } + } else { + _creatingStatus.value = Status.Error + _creating.value = false + } } } } @@ -228,15 +259,18 @@ private fun SettingsBackupScreenContent( creatingProgress: Float?, creatingStatus: SettingsBackupViewModel.Status, missingSourceFlow: SharedFlow>>, - createFlow: SharedFlow Unit>>, - restoreFile: (Path?) -> Unit, + createFlow: SharedFlow, + restoreFile: (Path) -> Unit, restoreBackup: (Path) -> Unit, stopRestore: () -> Unit, - exportBackup: () -> Unit + exportBackup: () -> Unit, + exportBackupFileFound: (Path) -> Unit ) { var backupFile by remember { mutableStateOf(null) } var missingSources by remember { mutableStateOf(emptyList()) } val dialogState = rememberMaterialDialogState() + val fileSaver = rememberFileSaver(exportBackupFileFound) + val fileChooser = rememberFileChooser(restoreFile) LaunchedEffect(Unit) { launch { missingSourceFlow.collect { (backup, sources) -> @@ -246,12 +280,14 @@ private fun SettingsBackupScreenContent( } } launch { - createFlow.collect { (filename, function) -> - fileSaver(filename, "proto.gz", onApprove = function) + createFlow.collect { filename -> + fileSaver.save(filename) } } } + + Scaffold( topBar = { Toolbar(stringResource(MR.strings.settings_backup_screen)) @@ -268,7 +304,7 @@ private fun SettingsBackupScreenContent( restoringProgress, restoreStatus ) { - filePicker("gz", onApprove = restoreFile) + fileChooser.launch("gz") } PreferenceFile( stringResource(MR.strings.backup_create), diff --git a/presentation/src/jvmMain/kotlin/ca/gosyer/ui/settings/SettingsLibraryScreen.kt b/presentation/src/jvmMain/kotlin/ca/gosyer/ui/settings/SettingsLibraryScreen.kt index e9dbcdbf..e904c463 100644 --- a/presentation/src/jvmMain/kotlin/ca/gosyer/ui/settings/SettingsLibraryScreen.kt +++ b/presentation/src/jvmMain/kotlin/ca/gosyer/ui/settings/SettingsLibraryScreen.kt @@ -6,14 +6,12 @@ package ca.gosyer.ui.settings -import ca.gosyer.ui.base.components.VerticalScrollbar import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.rememberLazyListState -import ca.gosyer.ui.base.components.rememberScrollbarAdapter import androidx.compose.material.Scaffold import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState @@ -23,6 +21,8 @@ import androidx.compose.ui.unit.dp import ca.gosyer.data.library.LibraryPreferences import ca.gosyer.data.server.interactions.CategoryInteractionHandler import ca.gosyer.i18n.MR +import ca.gosyer.ui.base.components.VerticalScrollbar +import ca.gosyer.ui.base.components.rememberScrollbarAdapter import ca.gosyer.ui.base.navigation.Toolbar import ca.gosyer.ui.base.prefs.PreferenceRow import ca.gosyer.ui.base.prefs.SwitchPreference @@ -35,6 +35,8 @@ import ca.gosyer.uicore.vm.viewModel import cafe.adriel.voyager.core.screen.Screen import cafe.adriel.voyager.core.screen.ScreenKey import cafe.adriel.voyager.core.screen.uniqueScreenKey +import cafe.adriel.voyager.navigator.LocalNavigator +import cafe.adriel.voyager.navigator.currentOrThrow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.launch @@ -46,10 +48,11 @@ class SettingsLibraryScreen : Screen { @Composable override fun Content() { val vm = viewModel() + val navigator = LocalNavigator.currentOrThrow SettingsLibraryScreenContent( showAllCategory = vm.showAllCategory, - refreshCategoryCount = vm::refreshCategoryCount, - categoriesSize = vm.categories.collectAsState().value + categoriesSize = vm.categories.collectAsState().value, + openCategoriesScreen = { openCategoriesMenu(vm::refreshCategoryCount, navigator) } ) } } @@ -78,8 +81,8 @@ class SettingsLibraryViewModel @Inject constructor( @Composable fun SettingsLibraryScreenContent( showAllCategory: PreferenceMutableStateFlow, - refreshCategoryCount: () -> Unit, - categoriesSize: Int + categoriesSize: Int, + openCategoriesScreen: () -> Unit ) { Scaffold( topBar = { @@ -98,7 +101,7 @@ fun SettingsLibraryScreenContent( item { PreferenceRow( stringResource(MR.strings.location_categories), - onClick = { openCategoriesMenu(refreshCategoryCount) }, + onClick = { openCategoriesScreen() }, subtitle = categoriesSize.toString() ) }