diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/ui/export/ExportScreen.kt b/app/src/main/java/de/jeanlucmakiola/agendula/ui/export/ExportScreen.kt index c9ae0f1..fb40226 100644 --- a/app/src/main/java/de/jeanlucmakiola/agendula/ui/export/ExportScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/agendula/ui/export/ExportScreen.kt @@ -1,40 +1,58 @@ package de.jeanlucmakiola.agendula.ui.export +import android.content.Context +import android.content.Intent +import android.provider.DocumentsContract import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.foundation.layout.Arrangement 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.fillMaxWidth import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.material.icons.Icons import androidx.compose.material.icons.rounded.Circle +import androidx.compose.material.icons.rounded.ErrorOutline import androidx.compose.material.icons.rounded.Folder import androidx.compose.material.icons.rounded.FolderZip import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.ButtonGroup +import androidx.compose.material3.ButtonGroupScope import androidx.compose.material3.Checkbox import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi +import androidx.compose.material3.FilledTonalButton import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.OutlinedButton import androidx.compose.material3.Text +import androidx.compose.material3.TextButton 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.graphics.Color +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.pluralStringResource import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp +import androidx.documentfile.provider.DocumentFile import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle import de.jeanlucmakiola.agendula.R import de.jeanlucmakiola.agendula.data.export.ExportFailure import de.jeanlucmakiola.floret.components.CollapsingScaffold +import de.jeanlucmakiola.floret.components.GroupedListInset import de.jeanlucmakiola.floret.components.GroupedRow import de.jeanlucmakiola.floret.components.Position import de.jeanlucmakiola.floret.components.pastelize @@ -42,6 +60,7 @@ import de.jeanlucmakiola.floret.components.positionOf private const val ZIP_MIME = "application/zip" private const val ZIP_NAME = "agendula-tasks.zip" +private const val EXTERNAL_STORAGE = "com.android.externalstorage.documents" /** * Export the task lists as iCalendar. Which lists go out is a per-list tick; the @@ -75,7 +94,7 @@ fun ExportScreen( text = stringResource(R.string.export_hint), style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.padding(horizontal = 16.dp, vertical = 4.dp), + modifier = Modifier.padding(horizontal = GroupedListInset, vertical = 4.dp), ) Spacer(Modifier.height(16.dp)) @@ -105,29 +124,11 @@ fun ExportScreen( } Spacer(Modifier.height(24.dp)) - Column( - modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp), - verticalArrangement = Arrangement.spacedBy(8.dp), - ) { - Button( - onClick = { folderLauncher.launch(null) }, - enabled = canExport, - modifier = Modifier.fillMaxWidth(), - ) { - Icon(Icons.Rounded.Folder, contentDescription = null, modifier = Modifier.size(18.dp)) - Spacer(Modifier.size(8.dp)) - Text(stringResource(R.string.export_to_folder)) - } - OutlinedButton( - onClick = { zipLauncher.launch(ZIP_NAME) }, - enabled = canExport, - modifier = Modifier.fillMaxWidth(), - ) { - Icon(Icons.Rounded.FolderZip, contentDescription = null, modifier = Modifier.size(18.dp)) - Spacer(Modifier.size(8.dp)) - Text(stringResource(R.string.export_to_zip)) - } - } + DestinationButtons( + enabled = canExport, + onFolder = { folderLauncher.launch(null) }, + onZip = { zipLauncher.launch(ZIP_NAME) }, + ) Spacer(Modifier.height(16.dp)) ExportStatus(state = state) @@ -135,13 +136,103 @@ fun ExportScreen( } } +/** + * The two destinations as one expressive button group: the pressed tile grows + * and squeezes its neighbour, and its corners morph square — the M3 Expressive + * pairing, and the same press language as the app's shaped top-bar actions. + */ +@OptIn(ExperimentalMaterial3ExpressiveApi::class) +@Composable +private fun DestinationButtons(enabled: Boolean, onFolder: () -> Unit, onZip: () -> Unit) { + ButtonGroup( + // Two weighted tiles always fit the row, so the group never overflows. + overflowIndicator = {}, + modifier = Modifier.fillMaxWidth().padding(horizontal = GroupedListInset), + ) { + customItem( + buttonGroupContent = { + DestinationButton( + icon = Icons.Rounded.Folder, + label = stringResource(R.string.export_to_folder), + onClick = onFolder, + enabled = enabled, + filled = true, + ) + }, + menuContent = {}, + ) + customItem( + buttonGroupContent = { + DestinationButton( + icon = Icons.Rounded.FolderZip, + label = stringResource(R.string.export_to_zip), + onClick = onZip, + enabled = enabled, + filled = false, + ) + }, + menuContent = {}, + ) + } +} + +@OptIn(ExperimentalMaterial3ExpressiveApi::class) +@Composable +private fun ButtonGroupScope.DestinationButton( + icon: ImageVector, + label: String, + onClick: () -> Unit, + enabled: Boolean, + filled: Boolean, +) { + val interaction = remember { MutableInteractionSource() } + val modifier = Modifier + .weight(1f) + .animateWidth(interaction) + .heightIn(min = 88.dp) + val content: @Composable () -> Unit = { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + Icon(icon, contentDescription = null, modifier = Modifier.size(24.dp)) + Text( + text = label, + style = MaterialTheme.typography.labelLarge, + textAlign = TextAlign.Center, + ) + } + } + if (filled) { + Button( + onClick = onClick, + shapes = ButtonDefaults.shapes(), + enabled = enabled, + interactionSource = interaction, + contentPadding = TILE_PADDING, + modifier = modifier, + ) { content() } + } else { + FilledTonalButton( + onClick = onClick, + shapes = ButtonDefaults.shapes(), + enabled = enabled, + interactionSource = interaction, + contentPadding = TILE_PADDING, + modifier = modifier, + ) { content() } + } +} + +private val TILE_PADDING = PaddingValues(horizontal = 12.dp, vertical = 16.dp) + /** The running spinner, then whatever the last export ended as — it stays put. */ @Composable private fun ExportStatus(state: ExportUiState) { val outcome = state.outcome when { state.running -> Row( - modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp), + modifier = Modifier.fillMaxWidth().padding(horizontal = GroupedListInset), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp), ) { @@ -152,17 +243,100 @@ private fun ExportStatus(state: ExportUiState) { color = MaterialTheme.colorScheme.onSurfaceVariant, ) } - outcome is ExportOutcome.Success -> StatusText( - text = pluralStringResource(R.plurals.export_done, outcome.fileCount, outcome.fileCount), - color = MaterialTheme.colorScheme.onSurfaceVariant, + outcome is ExportOutcome.Success -> Receipt( + title = pluralStringResource(R.plurals.export_done, outcome.fileCount, outcome.fileCount), + destination = outcome.destination, + container = MaterialTheme.colorScheme.secondaryContainer, + contentColor = MaterialTheme.colorScheme.onSecondaryContainer, ) - outcome is ExportOutcome.Failure -> StatusText( - text = stringResource(failureMessage(outcome.reason)), - color = MaterialTheme.colorScheme.error, + outcome is ExportOutcome.Failure -> Receipt( + title = stringResource(failureMessage(outcome.reason)), + destination = outcome.destination, + container = MaterialTheme.colorScheme.errorContainer, + contentColor = MaterialTheme.colorScheme.onErrorContainer, + icon = Icons.Rounded.ErrorOutline, ) } } +/** + * What the last export ended as, over *where* it was aimed: the folder or zip + * the user picked, named, with an Open button when something on the device can + * answer for it. + */ +@Composable +private fun Receipt( + title: String, + destination: ExportDestination, + container: Color, + contentColor: Color, + icon: ImageVector? = null, +) { + val context = LocalContext.current + val name = remember(destination) { destinationName(context, destination) } + val open = remember(destination) { openIntent(destination)?.takeIf { context.canHandle(it) } } + + GroupedRow( + title = title, + summary = name, + position = Position.Alone, + container = container, + contentColor = contentColor, + leading = { + Icon( + imageVector = icon + ?: if (destination.isFolder) Icons.Rounded.Folder else Icons.Rounded.FolderZip, + contentDescription = null, + ) + }, + trailing = if (open == null) { + null + } else { + { + TextButton( + onClick = { runCatching { context.startActivity(open) } }, + colors = ButtonDefaults.textButtonColors(contentColor = contentColor), + ) { Text(stringResource(R.string.export_open)) } + } + }, + ) +} + +/** + * The destination as the user would name it. The device's own provider spells the + * path into the tree's document id (`primary:Documents/Tasks`); every other + * provider's id is opaque, so those fall back to the display name. + */ +private fun destinationName(context: Context, destination: ExportDestination): String? = runCatching { + if (!destination.isFolder) return@runCatching DocumentFile.fromSingleUri(context, destination.uri)?.name + if (destination.uri.authority == EXTERNAL_STORAGE) { + DocumentsContract.getTreeDocumentId(destination.uri).substringAfter(':', "").trim('/') + .takeIf { it.isNotBlank() } + ?: DocumentFile.fromTreeUri(context, destination.uri)?.name + } else { + DocumentFile.fromTreeUri(context, destination.uri)?.name + } +}.getOrNull()?.takeIf { it.isNotBlank() } + +/** Null when the tree `Uri` will not yield a document to point at. */ +private fun openIntent(destination: ExportDestination): Intent? = runCatching { + val uri = if (destination.isFolder) { + DocumentsContract.buildDocumentUriUsingTree( + destination.uri, + DocumentsContract.getTreeDocumentId(destination.uri), + ) + } else { + destination.uri + } + val mime = if (destination.isFolder) DocumentsContract.Document.MIME_TYPE_DIR else ZIP_MIME + Intent(Intent.ACTION_VIEW) + .setDataAndType(uri, mime) + .addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) +}.getOrNull() + +private fun Context.canHandle(intent: Intent): Boolean = + packageManager.queryIntentActivities(intent, 0).isNotEmpty() + private fun failureMessage(reason: ExportFailure): Int = when (reason) { ExportFailure.FOLDER_UNAVAILABLE -> R.string.export_failed_folder ExportFailure.FOLDER_NOT_WRITABLE -> R.string.export_failed_read_only @@ -170,13 +344,3 @@ private fun failureMessage(reason: ExportFailure): Int = when (reason) { ExportFailure.LOST_ACCESS -> R.string.export_failed_access ExportFailure.WRITE_FAILED -> R.string.export_failed } - -@Composable -private fun StatusText(text: String, color: Color) { - Text( - text = text, - style = MaterialTheme.typography.bodyMedium, - color = color, - modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp), - ) -} diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/ui/export/ExportViewModel.kt b/app/src/main/java/de/jeanlucmakiola/agendula/ui/export/ExportViewModel.kt index f091b9c..df45e8d 100644 --- a/app/src/main/java/de/jeanlucmakiola/agendula/ui/export/ExportViewModel.kt +++ b/app/src/main/java/de/jeanlucmakiola/agendula/ui/export/ExportViewModel.kt @@ -25,10 +25,26 @@ import kotlinx.coroutines.launch import javax.inject.Inject import kotlin.coroutines.cancellation.CancellationException +/** + * Where an export was aimed: the SAF `Uri` the user picked and whether it was a + * folder or a single zip. Kept on the outcome so the receipt can name the place + * and offer to open it — the `Uri` is the only handle we have on it. + */ +data class ExportDestination(val uri: Uri, val isFolder: Boolean) + /** How the last export ended, kept on screen rather than flashed past. */ sealed interface ExportOutcome { - data class Success(val fileCount: Int) : ExportOutcome - data class Failure(val reason: ExportFailure) : ExportOutcome + val destination: ExportDestination + + data class Success( + val fileCount: Int, + override val destination: ExportDestination, + ) : ExportOutcome + + data class Failure( + val reason: ExportFailure, + override val destination: ExportDestination, + ) : ExportOutcome } data class ExportUiState( @@ -90,12 +106,19 @@ class ExportViewModel @Inject constructor( } /** Writes one `.ics` per list into a folder the user picked through SAF. */ - fun exportToFolder(tree: Uri) = export { documents -> writer.writeToTree(tree, documents) } + fun exportToFolder(tree: Uri) = export(ExportDestination(tree, isFolder = true)) { documents -> + writer.writeToTree(tree, documents) + } /** Writes every list into a single zip the user named through SAF. */ - fun exportToZip(target: Uri) = export { documents -> writer.writeZip(target, documents) } + fun exportToZip(target: Uri) = export(ExportDestination(target, isFolder = false)) { documents -> + writer.writeZip(target, documents) + } - private fun export(write: suspend (List) -> ExportResult) { + private fun export( + destination: ExportDestination, + write: suspend (List) -> ExportResult, + ) { if (running.value) return running.value = true outcome.value = null @@ -106,13 +129,14 @@ class ExportViewModel @Inject constructor( ?.let { skipped -> state.value.lists.map { it.id }.toSet() - skipped } try { val result = write(exporter.export(selection)) - outcome.value = ExportOutcome.Success(result.fileCount) + outcome.value = ExportOutcome.Success(result.fileCount, destination) } catch (cancelled: CancellationException) { // Leaving the screen mid-write is not a failed export. throw cancelled } catch (error: Exception) { outcome.value = ExportOutcome.Failure( (error as? ExportFailedException)?.failure ?: ExportFailure.WRITE_FAILED, + destination, ) } finally { running.value = false diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 1967390..89da8e7 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -274,6 +274,7 @@ Save to a folder Save as a zip file Exporting… + Open Exported %1$d list Exported %1$d lists diff --git a/floret-kit b/floret-kit index 3dc3448..d7b7a54 160000 --- a/floret-kit +++ b/floret-kit @@ -1 +1 @@ -Subproject commit 3dc34482654a5268ce9ce9b30808515d37f18a43 +Subproject commit d7b7a542cd50e278d585cccbf2936b1ab66f799a