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:
2026-09-09 13:49:02 +02:00
parent eb43f5d259
commit bd16fc4c65
4 changed files with 238 additions and 49 deletions
@@ -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
+1
View File
@@ -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>