aboutsummaryrefslogtreecommitdiffstats
path: root/ui
diff options
context:
space:
mode:
authorDanilo M. <danix@danix.xyz>2026-11-02 20:47:58 +0100
committerDanilo M. <danix@danix.xyz>2026-11-02 20:47:58 +0100
commit940b3018b6fec2366257aac496da3335e8e4cbd7 (patch)
tree64757ff3a51f3066900fae0dd7815b24dc479196 /ui
parent52b0b86ce7430118ab988f784d6422ff8b780c93 (diff)
downloadHardenedPass-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')
-rw-r--r--ui/compose/src/main/kotlin/app/passwordstore/ui/compose/Breadcrumbs.kt66
-rw-r--r--ui/compose/src/main/kotlin/app/passwordstore/ui/compose/EntryRows.kt137
-rw-r--r--ui/compose/src/main/kotlin/app/passwordstore/ui/compose/FieldRow.kt95
-rw-r--r--ui/compose/src/main/kotlin/app/passwordstore/ui/compose/GroupedSurface.kt32
-rw-r--r--ui/compose/src/main/res/drawable/ic_folder_24.xml9
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>