diff options
| author | Danilo M. <danix@danix.xyz> | 2026-11-02 20:47:58 +0100 |
|---|---|---|
| committer | Danilo M. <danix@danix.xyz> | 2026-11-02 20:47:58 +0100 |
| commit | 940b3018b6fec2366257aac496da3335e8e4cbd7 (patch) | |
| tree | 64757ff3a51f3066900fae0dd7815b24dc479196 /ui | |
| parent | 52b0b86ce7430118ab988f784d6422ff8b780c93 (diff) | |
| download | HardenedPass-940b3018b6fec2366257aac496da3335e8e4cbd7.tar.gz HardenedPass-940b3018b6fec2366257aac496da3335e8e4cbd7.zip | |
feat(ui-compose): add breadcrumb bar, grouped surface, entry and field rows
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Diffstat (limited to 'ui')
5 files changed, 339 insertions, 0 deletions
diff --git a/ui/compose/src/main/kotlin/app/passwordstore/ui/compose/Breadcrumbs.kt b/ui/compose/src/main/kotlin/app/passwordstore/ui/compose/Breadcrumbs.kt index d8bc02fe..3e51d1b4 100644 --- a/ui/compose/src/main/kotlin/app/passwordstore/ui/compose/Breadcrumbs.kt +++ b/ui/compose/src/main/kotlin/app/passwordstore/ui/compose/Breadcrumbs.kt @@ -5,6 +5,27 @@ package app.passwordstore.ui.compose +import androidx.compose.foundation.horizontalScroll +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight +import androidx.compose.material.icons.filled.Home +import androidx.compose.material3.AssistChip +import androidx.compose.material3.AssistChipDefaults +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import app.passwordstore.ui.compose.preview.ThemePreviews +import app.passwordstore.ui.compose.theme.APSTheme +import app.passwordstore.ui.compose.theme.SpacingLarge +import app.passwordstore.ui.compose.theme.SpacingSmall + /** One breadcrumb chip. The root crumb has an empty [label] and [path]. */ public data class Crumb(val label: String, val path: String) @@ -14,3 +35,48 @@ public fun crumbsFor(relativePath: String): List<Crumb> { return listOf(Crumb("", "")) + parts.indices.map { i -> Crumb(parts[i], parts.subList(0, i + 1).joinToString("/")) } } + +/** + * Folder location as chips. The last crumb is highlighted; tapping any crumb reports its path. + * [rootDescription] is the content description of the root (home) chip. + */ +@Composable +@Suppress("ComposeUnstableCollections") +public fun BreadcrumbBar( + crumbs: List<Crumb>, + rootDescription: String, + onCrumbClick: (path: String) -> Unit, + modifier: Modifier = Modifier, +) { + Row( + modifier = modifier.horizontalScroll(rememberScrollState()).padding(horizontal = SpacingLarge), + horizontalArrangement = Arrangement.spacedBy(SpacingSmall), + verticalAlignment = Alignment.CenterVertically, + ) { + crumbs.forEachIndexed { i, crumb -> + if (i > 0) Icon(Icons.AutoMirrored.Filled.KeyboardArrowRight, contentDescription = null) + val current = i == crumbs.lastIndex + AssistChip( + onClick = { onCrumbClick(crumb.path) }, + label = { + if (crumb.path.isEmpty()) Icon(Icons.Filled.Home, contentDescription = rootDescription) + else Text(crumb.label) + }, + colors = + if (current) + AssistChipDefaults.assistChipColors( + containerColor = MaterialTheme.colorScheme.secondaryContainer, + labelColor = MaterialTheme.colorScheme.onSecondaryContainer, + ) + else AssistChipDefaults.assistChipColors(), + border = null, + ) + } + } +} + +@ThemePreviews +@Composable +private fun BreadcrumbBarPreview() { + APSTheme { BreadcrumbBar(crumbsFor("work/servers"), "Store root", onCrumbClick = {}) } +} diff --git a/ui/compose/src/main/kotlin/app/passwordstore/ui/compose/EntryRows.kt b/ui/compose/src/main/kotlin/app/passwordstore/ui/compose/EntryRows.kt new file mode 100644 index 00000000..56ceb5b5 --- /dev/null +++ b/ui/compose/src/main/kotlin/app/passwordstore/ui/compose/EntryRows.kt @@ -0,0 +1,137 @@ +/* + * Copyright (C) 2026 Danilo M. <danix@danix.xyz> + * SPDX-License-Identifier: GPL-3.0-only + */ + +package app.passwordstore.ui.compose + +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.background +import androidx.compose.foundation.combinedClickable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.RowScope +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.unit.dp +import app.passwordstore.ui.compose.preview.ThemePreviews +import app.passwordstore.ui.compose.theme.APSTheme +import app.passwordstore.ui.compose.theme.SpacingLarge +import app.passwordstore.ui.compose.theme.SpacingMedium + +/** Circle with the first letter of [name]; the color pair is picked from the name's hash. */ +@Composable +public fun LetterAvatar(name: String, modifier: Modifier = Modifier) { + val scheme = MaterialTheme.colorScheme + val pairs = + listOf( + scheme.primaryContainer to scheme.onPrimaryContainer, + scheme.secondaryContainer to scheme.onSecondaryContainer, + scheme.tertiaryContainer to scheme.onTertiaryContainer, + ) + val (bg, fg) = pairs[Math.floorMod(name.hashCode(), pairs.size)] + Box(modifier.size(36.dp).background(bg, CircleShape), contentAlignment = Alignment.Center) { + Text(name.take(1).uppercase(), color = fg, style = MaterialTheme.typography.titleMedium) + } +} + +@OptIn(ExperimentalFoundationApi::class) +@Composable +private fun RowShell( + onClick: () -> Unit, + onLongClick: () -> Unit, + selected: Boolean, + modifier: Modifier = Modifier, + content: @Composable RowScope.() -> Unit, +) { + Row( + modifier = + modifier + .fillMaxWidth() + .background( + if (selected) MaterialTheme.colorScheme.secondaryContainer else Color.Transparent + ) + .combinedClickable(onClick = onClick, onLongClick = onLongClick) + .padding(horizontal = SpacingLarge, vertical = SpacingMedium + 2.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + content() + } +} + +/** A folder row: icon, name, entry count. */ +@Composable +public fun FolderRow( + name: String, + count: Int, + onClick: () -> Unit, + modifier: Modifier = Modifier, + selected: Boolean = false, + onLongClick: () -> Unit = {}, +) { + RowShell(onClick, onLongClick, selected, modifier) { + Box( + Modifier.size(36.dp).background(MaterialTheme.colorScheme.secondaryContainer, CircleShape), + contentAlignment = Alignment.Center, + ) { + Icon( + painterResource(R.drawable.ic_folder_24), + contentDescription = null, + tint = MaterialTheme.colorScheme.onSecondaryContainer, + ) + } + Text(name, Modifier.weight(1f).padding(start = SpacingLarge)) + Text( + count.toString(), + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } +} + +/** An entry row: letter avatar, name, optional subtitle (username, "OTP"). */ +@Composable +public fun EntryRow( + name: String, + subtitle: String?, + onClick: () -> Unit, + modifier: Modifier = Modifier, + selected: Boolean = false, + onLongClick: () -> Unit = {}, +) { + RowShell(onClick, onLongClick, selected, modifier) { + LetterAvatar(name) + Column(Modifier.weight(1f).padding(start = SpacingLarge)) { + Text(name) + if (subtitle != null) + Text( + subtitle, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } +} + +@ThemePreviews +@Composable +private fun RowsPreview() { + APSTheme { + GroupedSurface { + FolderRow("legacy", 2, onClick = {}) + EntryRow("git.example.org", "you", onClick = {}) + EntryRow("vpn", "you · OTP", onClick = {}, selected = true) + } + } +} diff --git a/ui/compose/src/main/kotlin/app/passwordstore/ui/compose/FieldRow.kt b/ui/compose/src/main/kotlin/app/passwordstore/ui/compose/FieldRow.kt new file mode 100644 index 00000000..c49ec2bc --- /dev/null +++ b/ui/compose/src/main/kotlin/app/passwordstore/ui/compose/FieldRow.kt @@ -0,0 +1,95 @@ +/* + * Copyright (C) 2026 Danilo M. <danix@danix.xyz> + * SPDX-License-Identifier: GPL-3.0-only + */ + +package app.passwordstore.ui.compose + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.painterResource +import app.passwordstore.ui.compose.preview.ThemePreviews +import app.passwordstore.ui.compose.theme.APSTheme +import app.passwordstore.ui.compose.theme.SpacingLarge +import app.passwordstore.ui.compose.theme.SpacingMedium + +/** Content descriptions for [FieldRow]'s buttons, supplied by the app's translated strings. */ +public data class FieldRowLabels(val copy: String, val show: String, val hide: String) + +/** + * A labelled value with an optional copy button. When [masked] is true the value is hidden behind + * dots until the reveal toggle is pressed. + */ +@Composable +public fun FieldRow( + label: String, + value: String, + labels: FieldRowLabels, + modifier: Modifier = Modifier, + masked: Boolean = false, + onCopy: (() -> Unit)? = null, +) { + var revealed by rememberSaveable { mutableStateOf(false) } + Row( + modifier + .fillMaxWidth() + .padding( + start = SpacingLarge, + end = SpacingMedium, + top = SpacingMedium, + bottom = SpacingMedium, + ), + verticalAlignment = Alignment.CenterVertically, + ) { + Column(Modifier.weight(1f)) { + Text( + label, + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Text( + if (masked && !revealed) "•".repeat(10) else value, + style = MaterialTheme.typography.bodyLarge, + ) + } + if (masked) + IconButton(onClick = { revealed = !revealed }) { + Icon( + painterResource( + if (revealed) R.drawable.baseline_visibility_off_24 + else R.drawable.baseline_visibility_24 + ), + contentDescription = if (revealed) labels.hide else labels.show, + ) + } + if (onCopy != null) + IconButton(onClick = onCopy) { + Icon(painterResource(R.drawable.ic_content_copy), contentDescription = labels.copy) + } + } +} + +@ThemePreviews +@Composable +private fun FieldRowPreview() { + val labels = FieldRowLabels("Copy", "Show", "Hide") + APSTheme { + GroupedSurface { + FieldRow("Password", "hunter2", labels, masked = true, onCopy = {}) + FieldRow("Username", "you", labels, onCopy = {}) + } + } +} diff --git a/ui/compose/src/main/kotlin/app/passwordstore/ui/compose/GroupedSurface.kt b/ui/compose/src/main/kotlin/app/passwordstore/ui/compose/GroupedSurface.kt new file mode 100644 index 00000000..5a86c549 --- /dev/null +++ b/ui/compose/src/main/kotlin/app/passwordstore/ui/compose/GroupedSurface.kt @@ -0,0 +1,32 @@ +/* + * Copyright (C) 2026 Danilo M. <danix@danix.xyz> + * SPDX-License-Identifier: GPL-3.0-only + */ + +package app.passwordstore.ui.compose + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import app.passwordstore.ui.compose.theme.SpacingMedium + +/** Rounded container that groups related rows (folders, entries, fields). */ +@Composable +public fun GroupedSurface( + modifier: Modifier = Modifier, + content: @Composable ColumnScope.() -> Unit, +) { + Surface( + modifier = modifier.padding(horizontal = SpacingMedium, vertical = SpacingMedium / 2), + shape = RoundedCornerShape(16.dp), + color = MaterialTheme.colorScheme.surfaceContainerLow, + ) { + Column(content = content) + } +} diff --git a/ui/compose/src/main/res/drawable/ic_folder_24.xml b/ui/compose/src/main/res/drawable/ic_folder_24.xml new file mode 100644 index 00000000..747e21a6 --- /dev/null +++ b/ui/compose/src/main/res/drawable/ic_folder_24.xml @@ -0,0 +1,9 @@ +<vector xmlns:android="http://schemas.android.com/apk/res/android" + android:width="24dp" + android:height="24dp" + android:viewportWidth="24" + android:viewportHeight="24"> + <path + android:fillColor="#FF000000" + android:pathData="M10,4H4c-1.1,0 -1.99,0.9 -1.99,2L2,18c0,1.1 0.9,2 2,2h16c1.1,0 2,-0.9 2,-2V8c0,-1.1 -0.9,-2 -2,-2h-8l-2,-2z" /> +</vector> |
