export: expressive destination buttons, and a receipt that says where it went
The two destinations were a filled Button over an OutlinedButton, and the success line said "Exported 1 list" and nothing else. Now they are an M3 Expressive ButtonGroup — equal-weight tiles that grow and squeeze their neighbour on press, corners morphing square — and the outcome carries the SAF Uri it was aimed at, so the receipt is a tinted row naming the folder or zip, with an Open button when something on the device answers for it. Failures get the same treatment.
This commit is contained in:
@@ -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),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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<ExportDocument>) -> ExportResult) {
|
||||
private fun export(
|
||||
destination: ExportDestination,
|
||||
write: suspend (List<ExportDocument>) -> 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
|
||||
|
||||
@@ -274,6 +274,7 @@
|
||||
<string name="export_to_folder">Save to a folder</string>
|
||||
<string name="export_to_zip">Save as a zip file</string>
|
||||
<string name="export_running">Exporting…</string>
|
||||
<string name="export_open">Open</string>
|
||||
<plurals name="export_done">
|
||||
<item quantity="one">Exported %1$d list</item>
|
||||
<item quantity="other">Exported %1$d lists</item>
|
||||
|
||||
Reference in New Issue
Block a user