refactor(android): further simplify settings page navigation

This commit is contained in:
Harsh Shandilya 2024-01-18 21:45:33 +05:30
parent 988cf117c2
commit 1885859d2f
6 changed files with 165 additions and 205 deletions

View file

@ -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)
}

View file

@ -31,10 +31,6 @@ sealed class Destinations {
override val route = "user/$PLACEHOLDER" override val route = "user/$PLACEHOLDER"
} }
data object DataTransfer : Destinations() {
override val route = "datatransfer"
}
data object Search : Destinations() { data object Search : Destinations() {
override val route = "search" override val route = "search"
} }

View file

@ -65,7 +65,6 @@ import com.mikepenz.aboutlibraries.ui.compose.m3.LibrariesContainer
import dev.msfjarvis.claw.android.MainActivity import dev.msfjarvis.claw.android.MainActivity
import dev.msfjarvis.claw.android.R import dev.msfjarvis.claw.android.R
import dev.msfjarvis.claw.android.SearchActivity 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.ClawAppBar
import dev.msfjarvis.claw.android.ui.decorations.ClawNavigationBar import dev.msfjarvis.claw.android.ui.decorations.ClawNavigationBar
import dev.msfjarvis.claw.android.ui.decorations.ClawNavigationRail import dev.msfjarvis.claw.android.ui.decorations.ClawNavigationRail
@ -282,21 +281,16 @@ fun LobstersPostsScreen(
setWebUri("https://lobste.rs/u/$username") setWebUri("https://lobste.rs/u/$username")
UserProfile(username = username, getProfile = viewModel::getUserProfile) UserProfile(username = username, getProfile = viewModel::getUserProfile)
} }
composable(route = Destinations.DataTransfer.route) { composable(route = Destinations.Settings.route) {
DataTransferScreen( SettingsScreen(
context = context, context = context,
openLibrariesScreen = { navController.navigate(Destinations.AboutLibraries.route) },
importPosts = viewModel::importPosts, importPosts = viewModel::importPosts,
exportPosts = viewModel::exportPosts, exportPostsAsJson = viewModel::exportPostsAsJson,
exportPostsAsHtml = viewModel::exportPostsAsHtml, exportPostsAsHtml = viewModel::exportPostsAsHtml,
snackbarHostState = snackbarHostState, 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) { composable(route = Destinations.AboutLibraries.route) {
LibrariesContainer(modifier = Modifier.fillMaxSize()) LibrariesContainer(modifier = Modifier.fillMaxSize())
} }

View file

@ -6,35 +6,181 @@
*/ */
package dev.msfjarvis.claw.android.ui.screens 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.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize 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.Icons
import androidx.compose.material.icons.automirrored.filled.LibraryBooks 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.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 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 @Composable
fun SettingsScreen( fun SettingsScreen(
context: Context,
openLibrariesScreen: () -> Unit, openLibrariesScreen: () -> Unit,
openDataTransferScreen: () -> Unit, snackbarHostState: SnackbarHostState,
importPosts: suspend (InputStream) -> Unit,
exportPostsAsJson: suspend (OutputStream) -> Unit,
exportPostsAsHtml: suspend (OutputStream) -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
val coroutineScope = rememberCoroutineScope()
Box(modifier = modifier.fillMaxSize()) { Box(modifier = modifier.fillMaxSize()) {
Column { Column {
SettingsActionItem( ListItem(
title = "Data transfer", headlineContent = { Text("Libraries") },
description = "Export and import your saved posts", leadingContent = {
icon = Icons.Filled.ImportExport, Icon(
onClick = openDataTransferScreen, imageVector = Icons.AutoMirrored.Filled.LibraryBooks,
contentDescription = null,
modifier = Modifier.height(32.dp),
)
},
modifier = Modifier.clickable(onClick = openLibrariesScreen),
) )
SettingsActionItem( Text(
title = "Libraries", text = "Data transfer",
icon = Icons.AutoMirrored.Filled.LibraryBooks, style = MaterialTheme.typography.labelMedium,
onClick = openLibrariesScreen, 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)
}

View file

@ -188,7 +188,8 @@ constructor(
suspend fun importPosts(input: InputStream) = dataTransferRepository.importPosts(input) 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) = suspend fun exportPostsAsHtml(output: OutputStream) =
dataTransferRepository.exportPostsAsHTML(output) dataTransferRepository.exportPostsAsHTML(output)

View file

@ -43,7 +43,7 @@ constructor(
} }
} }
suspend fun exportPosts(output: OutputStream) { suspend fun exportPostsAsJson(output: OutputStream) {
val posts = withContext(dbDispatcher) { savedPostQueries.selectAllPosts().executeAsList() } val posts = withContext(dbDispatcher) { savedPostQueries.selectAllPosts().executeAsList() }
withContext(ioDispatcher) { json.encodeToStream(serializer, posts, output) } withContext(ioDispatcher) { json.encodeToStream(serializer, posts, output) }
} }