From 1885859d2fe1e6b65787e54fd50958588aa182ec Mon Sep 17 00:00:00 2001 From: Harsh Shandilya Date: Thu, 18 Jan 2024 21:45:33 +0530 Subject: [PATCH] refactor(android): further simplify settings page navigation --- .../ui/datatransfer/DataTransferScreen.kt | 177 ------------------ .../android/ui/navigation/Destinations.kt | 4 - .../android/ui/screens/LobstersPostsScreen.kt | 14 +- .../claw/android/ui/screens/SettingsScreen.kt | 170 +++++++++++++++-- .../claw/android/viewmodel/ClawViewModel.kt | 3 +- .../viewmodel/DataTransferRepository.kt | 2 +- 6 files changed, 165 insertions(+), 205 deletions(-) delete mode 100644 android/src/main/kotlin/dev/msfjarvis/claw/android/ui/datatransfer/DataTransferScreen.kt diff --git a/android/src/main/kotlin/dev/msfjarvis/claw/android/ui/datatransfer/DataTransferScreen.kt b/android/src/main/kotlin/dev/msfjarvis/claw/android/ui/datatransfer/DataTransferScreen.kt deleted file mode 100644 index 1210428a..00000000 --- a/android/src/main/kotlin/dev/msfjarvis/claw/android/ui/datatransfer/DataTransferScreen.kt +++ /dev/null @@ -1,177 +0,0 @@ -/* - * Copyright © 2023-2024 Harsh Shandilya. - * Use of this source code is governed by an MIT-style - * license that can be found in the LICENSE file or at - * https://opensource.org/licenses/MIT. - */ -package dev.msfjarvis.claw.android.ui.datatransfer - -import android.content.Context -import androidx.activity.compose.rememberLauncherForActivityResult -import androidx.activity.result.contract.ActivityResultContracts.CreateDocument -import androidx.activity.result.contract.ActivityResultContracts.GetContent -import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.height -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Download -import androidx.compose.material.icons.filled.Upload -import androidx.compose.material.icons.filled.WebStories -import androidx.compose.material3.Icon -import androidx.compose.material3.ListItem -import androidx.compose.material3.SnackbarHostState -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.runtime.rememberCoroutineScope -import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.vector.ImageVector -import androidx.compose.ui.unit.dp -import java.io.InputStream -import java.io.OutputStream -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.launch - -private const val JSON_MIME_TYPE = "application/json" -private const val HTML_MIME_TYPE = "application/html" - -@Composable -fun DataTransferScreen( - context: Context, - importPosts: suspend (InputStream) -> Unit, - exportPosts: suspend (OutputStream) -> Unit, - exportPostsAsHtml: suspend (OutputStream) -> Unit, - snackbarHostState: SnackbarHostState, - modifier: Modifier = Modifier, -) { - val coroutineScope = rememberCoroutineScope() - Column(modifier = modifier) { - JsonImportOption(context, coroutineScope, importPosts, snackbarHostState) - JsonExportOption(context, coroutineScope, exportPosts, snackbarHostState) - HtmlExportOption(context, coroutineScope, exportPostsAsHtml, snackbarHostState) - } -} - -@Composable -private fun JsonImportOption( - context: Context, - coroutineScope: CoroutineScope, - importPosts: suspend (InputStream) -> Unit, - snackbarHostState: SnackbarHostState, -) { - val importAction = - rememberLauncherForActivityResult(GetContent()) { uri -> - if (uri == null) { - coroutineScope.launch { snackbarHostState.showSnackbarDismissing("No file selected") } - return@rememberLauncherForActivityResult - } - coroutineScope.launch { - context.contentResolver.openInputStream(uri)?.use { stream -> - importPosts(stream) - snackbarHostState.showSnackbarDismissing("Successfully imported posts") - } - } - } - SettingsActionItem( - title = "Import saved posts", - description = "Import saved posts from a previously generated JSON export", - icon = Icons.Filled.Download, - ) { - importAction.launch(JSON_MIME_TYPE) - } -} - -@Composable -private fun JsonExportOption( - context: Context, - coroutineScope: CoroutineScope, - exportPosts: suspend (OutputStream) -> Unit, - snackbarHostState: SnackbarHostState, -) { - GenericExportOption( - title = "Export posts to file", - description = "Write all saved posts into a JSON file that can be imported at a later date", - icon = Icons.Filled.Upload, - fileName = "claw-export.json", - mimeType = JSON_MIME_TYPE, - context = context, - coroutineScope = coroutineScope, - exportPosts = exportPosts, - snackbarHostState = snackbarHostState, - ) -} - -@Composable -private fun HtmlExportOption( - context: Context, - coroutineScope: CoroutineScope, - exportPosts: suspend (OutputStream) -> Unit, - snackbarHostState: SnackbarHostState, -) { - GenericExportOption( - title = "Export posts as bookmarks", - description = "Write all saved posts into a HTML file that can be imported by web browsers", - icon = Icons.Filled.WebStories, - fileName = "claw-export.html", - mimeType = HTML_MIME_TYPE, - context = context, - coroutineScope = coroutineScope, - exportPosts = exportPosts, - snackbarHostState = snackbarHostState, - ) -} - -@Composable -private fun GenericExportOption( - title: String, - description: String, - icon: ImageVector, - fileName: String, - mimeType: String, - context: Context, - coroutineScope: CoroutineScope, - exportPosts: suspend (OutputStream) -> Unit, - snackbarHostState: SnackbarHostState, -) { - val exportAction = - rememberLauncherForActivityResult(CreateDocument(mimeType)) { uri -> - if (uri == null) { - coroutineScope.launch { snackbarHostState.showSnackbarDismissing("No file selected") } - return@rememberLauncherForActivityResult - } - coroutineScope.launch { - context.contentResolver.openOutputStream(uri)?.use { stream -> - exportPosts(stream) - snackbarHostState.showSnackbarDismissing("Successfully exported posts") - } - } - } - SettingsActionItem(title = title, description = description, icon = icon) { - exportAction.launch(fileName) - } -} - -@Composable -fun SettingsActionItem( - title: String, - modifier: Modifier = Modifier, - description: String? = null, - icon: ImageVector? = null, - onClick: (() -> Unit)? = null, -) { - ListItem( - headlineContent = { Text(title) }, - supportingContent = { description?.let { Text(it) } }, - leadingContent = { - icon?.let { - Icon(imageVector = icon, contentDescription = null, modifier = Modifier.height(32.dp)) - } - }, - modifier = modifier.clickable { onClick?.invoke() }, - ) -} - -/** Shows a Snackbar but dismisses any existing ones first. */ -private suspend fun SnackbarHostState.showSnackbarDismissing(text: String) { - currentSnackbarData?.dismiss() - showSnackbar(text) -} diff --git a/android/src/main/kotlin/dev/msfjarvis/claw/android/ui/navigation/Destinations.kt b/android/src/main/kotlin/dev/msfjarvis/claw/android/ui/navigation/Destinations.kt index eae83002..1512067a 100644 --- a/android/src/main/kotlin/dev/msfjarvis/claw/android/ui/navigation/Destinations.kt +++ b/android/src/main/kotlin/dev/msfjarvis/claw/android/ui/navigation/Destinations.kt @@ -31,10 +31,6 @@ sealed class Destinations { override val route = "user/$PLACEHOLDER" } - data object DataTransfer : Destinations() { - override val route = "datatransfer" - } - data object Search : Destinations() { override val route = "search" } diff --git a/android/src/main/kotlin/dev/msfjarvis/claw/android/ui/screens/LobstersPostsScreen.kt b/android/src/main/kotlin/dev/msfjarvis/claw/android/ui/screens/LobstersPostsScreen.kt index eb1ca0d6..f5acc7b9 100644 --- a/android/src/main/kotlin/dev/msfjarvis/claw/android/ui/screens/LobstersPostsScreen.kt +++ b/android/src/main/kotlin/dev/msfjarvis/claw/android/ui/screens/LobstersPostsScreen.kt @@ -65,7 +65,6 @@ import com.mikepenz.aboutlibraries.ui.compose.m3.LibrariesContainer import dev.msfjarvis.claw.android.MainActivity import dev.msfjarvis.claw.android.R import dev.msfjarvis.claw.android.SearchActivity -import dev.msfjarvis.claw.android.ui.datatransfer.DataTransferScreen import dev.msfjarvis.claw.android.ui.decorations.ClawAppBar import dev.msfjarvis.claw.android.ui.decorations.ClawNavigationBar import dev.msfjarvis.claw.android.ui.decorations.ClawNavigationRail @@ -282,21 +281,16 @@ fun LobstersPostsScreen( setWebUri("https://lobste.rs/u/$username") UserProfile(username = username, getProfile = viewModel::getUserProfile) } - composable(route = Destinations.DataTransfer.route) { - DataTransferScreen( + composable(route = Destinations.Settings.route) { + SettingsScreen( context = context, + openLibrariesScreen = { navController.navigate(Destinations.AboutLibraries.route) }, importPosts = viewModel::importPosts, - exportPosts = viewModel::exportPosts, + exportPostsAsJson = viewModel::exportPostsAsJson, exportPostsAsHtml = viewModel::exportPostsAsHtml, snackbarHostState = snackbarHostState, ) } - composable(route = Destinations.Settings.route) { - SettingsScreen( - openLibrariesScreen = { navController.navigate(Destinations.AboutLibraries.route) }, - openDataTransferScreen = { navController.navigate(Destinations.DataTransfer.route) }, - ) - } composable(route = Destinations.AboutLibraries.route) { LibrariesContainer(modifier = Modifier.fillMaxSize()) } diff --git a/android/src/main/kotlin/dev/msfjarvis/claw/android/ui/screens/SettingsScreen.kt b/android/src/main/kotlin/dev/msfjarvis/claw/android/ui/screens/SettingsScreen.kt index dbbe67ad..3c466164 100644 --- a/android/src/main/kotlin/dev/msfjarvis/claw/android/ui/screens/SettingsScreen.kt +++ b/android/src/main/kotlin/dev/msfjarvis/claw/android/ui/screens/SettingsScreen.kt @@ -6,35 +6,181 @@ */ package dev.msfjarvis.claw.android.ui.screens +import android.content.Context +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.LibraryBooks -import androidx.compose.material.icons.filled.ImportExport +import androidx.compose.material.icons.filled.Upload +import androidx.compose.material.icons.filled.WebStories +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.ElevatedButton +import androidx.compose.material3.Icon +import androidx.compose.material3.ListItem +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier -import dev.msfjarvis.claw.android.ui.datatransfer.SettingsActionItem +import androidx.compose.ui.unit.dp +import java.io.InputStream +import java.io.OutputStream +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.launch + +private const val JSON_MIME_TYPE = "application/json" +private const val HTML_MIME_TYPE = "application/html" @Composable fun SettingsScreen( + context: Context, openLibrariesScreen: () -> Unit, - openDataTransferScreen: () -> Unit, + snackbarHostState: SnackbarHostState, + importPosts: suspend (InputStream) -> Unit, + exportPostsAsJson: suspend (OutputStream) -> Unit, + exportPostsAsHtml: suspend (OutputStream) -> Unit, modifier: Modifier = Modifier, ) { + val coroutineScope = rememberCoroutineScope() Box(modifier = modifier.fillMaxSize()) { Column { - SettingsActionItem( - title = "Data transfer", - description = "Export and import your saved posts", - icon = Icons.Filled.ImportExport, - onClick = openDataTransferScreen, + ListItem( + headlineContent = { Text("Libraries") }, + leadingContent = { + Icon( + imageVector = Icons.AutoMirrored.Filled.LibraryBooks, + contentDescription = null, + modifier = Modifier.height(32.dp), + ) + }, + modifier = Modifier.clickable(onClick = openLibrariesScreen), ) - SettingsActionItem( - title = "Libraries", - icon = Icons.AutoMirrored.Filled.LibraryBooks, - onClick = openLibrariesScreen, + Text( + text = "Data transfer", + style = MaterialTheme.typography.labelMedium, + modifier = Modifier.padding(all = 16.dp), ) + Row(horizontalArrangement = Arrangement.SpaceAround, modifier = Modifier.fillMaxWidth()) { + ImportPosts(context, coroutineScope, snackbarHostState, importPosts) + ExportPosts( + context, + coroutineScope, + snackbarHostState, + exportPostsAsJson, + exportPostsAsHtml, + ) + } } } } + +@Composable +private fun ImportPosts( + context: Context, + coroutineScope: CoroutineScope, + snackbarHostState: SnackbarHostState, + importPosts: suspend (InputStream) -> Unit, +) { + val importAction = + rememberLauncherForActivityResult(ActivityResultContracts.GetContent()) { uri -> + if (uri == null) { + coroutineScope.launch { snackbarHostState.showSnackbarDismissing("No file selected") } + return@rememberLauncherForActivityResult + } + coroutineScope.launch { + context.contentResolver.openInputStream(uri)?.use { stream -> + importPosts(stream) + snackbarHostState.showSnackbarDismissing("Successfully imported posts") + } + } + } + ElevatedButton(onClick = { importAction.launch(JSON_MIME_TYPE) }) { Text(text = "Import") } +} + +@Composable +private fun ExportPosts( + context: Context, + coroutineScope: CoroutineScope, + snackbarHostState: SnackbarHostState, + exportPostsAsJson: suspend (OutputStream) -> Unit, + exportPostsAsHtml: suspend (OutputStream) -> Unit, +) { + val jsonExportAction = + rememberLauncherForActivityResult(ActivityResultContracts.CreateDocument(JSON_MIME_TYPE)) { uri + -> + if (uri == null) { + coroutineScope.launch { snackbarHostState.showSnackbarDismissing("No file selected") } + return@rememberLauncherForActivityResult + } + coroutineScope.launch { + context.contentResolver.openOutputStream(uri)?.use { stream -> + exportPostsAsJson(stream) + snackbarHostState.showSnackbarDismissing("Successfully exported posts") + } + } + } + val htmlExportAction = + rememberLauncherForActivityResult(ActivityResultContracts.CreateDocument(HTML_MIME_TYPE)) { uri + -> + if (uri == null) { + coroutineScope.launch { snackbarHostState.showSnackbarDismissing("No file selected") } + return@rememberLauncherForActivityResult + } + coroutineScope.launch { + context.contentResolver.openOutputStream(uri)?.use { stream -> + exportPostsAsHtml(stream) + snackbarHostState.showSnackbarDismissing("Successfully exported posts") + } + } + } + var expanded by remember { mutableStateOf(false) } + ElevatedButton(onClick = { expanded = true }) { + Text(text = "Export") + DropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) { + DropdownMenuItem( + text = { Text("JSON") }, + onClick = { + expanded = false + jsonExportAction.launch("claw-export.json") + }, + leadingIcon = { + Icon(imageVector = Icons.Filled.Upload, contentDescription = "Export as JSON") + }, + ) + DropdownMenuItem( + text = { Text("Bookmarks") }, + onClick = { + expanded = false + htmlExportAction.launch("claw-export.html") + }, + leadingIcon = { + Icon( + imageVector = Icons.Filled.WebStories, + contentDescription = "Export as browser bookmarks", + ) + }, + ) + } + } +} + +/** Shows a Snackbar but dismisses any existing ones first. */ +private suspend fun SnackbarHostState.showSnackbarDismissing(text: String) { + currentSnackbarData?.dismiss() + showSnackbar(text) +} diff --git a/android/src/main/kotlin/dev/msfjarvis/claw/android/viewmodel/ClawViewModel.kt b/android/src/main/kotlin/dev/msfjarvis/claw/android/viewmodel/ClawViewModel.kt index a6e09e44..f56c33af 100644 --- a/android/src/main/kotlin/dev/msfjarvis/claw/android/viewmodel/ClawViewModel.kt +++ b/android/src/main/kotlin/dev/msfjarvis/claw/android/viewmodel/ClawViewModel.kt @@ -188,7 +188,8 @@ constructor( suspend fun importPosts(input: InputStream) = dataTransferRepository.importPosts(input) - suspend fun exportPosts(output: OutputStream) = dataTransferRepository.exportPosts(output) + suspend fun exportPostsAsJson(output: OutputStream) = + dataTransferRepository.exportPostsAsJson(output) suspend fun exportPostsAsHtml(output: OutputStream) = dataTransferRepository.exportPostsAsHTML(output) diff --git a/android/src/main/kotlin/dev/msfjarvis/claw/android/viewmodel/DataTransferRepository.kt b/android/src/main/kotlin/dev/msfjarvis/claw/android/viewmodel/DataTransferRepository.kt index bf45d622..5daf0613 100644 --- a/android/src/main/kotlin/dev/msfjarvis/claw/android/viewmodel/DataTransferRepository.kt +++ b/android/src/main/kotlin/dev/msfjarvis/claw/android/viewmodel/DataTransferRepository.kt @@ -43,7 +43,7 @@ constructor( } } - suspend fun exportPosts(output: OutputStream) { + suspend fun exportPostsAsJson(output: OutputStream) { val posts = withContext(dbDispatcher) { savedPostQueries.selectAllPosts().executeAsList() } withContext(ioDispatcher) { json.encodeToStream(serializer, posts, output) } }