b9c49cac9cf8

fix: Refine Entry detail editing and card layout (v2.14.42)

Move optional identity fields, password history, tags, and Canary controls into Edit; keep populated tags above the URL card; replace the TOTP ring with a liquid countdown fill; and add inline frequency-ranked username/email suggestions classified by @. Tighten the hero spacing so fixed Entry controls remain above the heavy fade.

time
2026-08-14 21:01 UTC · 2 months ago
machine
koh2
parent
a67c56557afe
10 files VERSION+1 −1app/src/main/java/com/peachpasswords/ui/vault/EditableFieldRow.kt+42 −33app/src/main/java/com/peachpasswords/ui/vault/EntryDetailScreen.kt+25 −112app/src/main/java/com/peachpasswords/ui/vault/EntryDetailViewModel.kt+8 −3app/src/main/java/com/peachpasswords/ui/vault/EntryEditScreen.kt+10 −0app/src/main/java/com/peachpasswords/ui/vault/EntryEditViewModel.kt+90 −9app/src/main/java/com/peachpasswords/ui/vault/TagChipEditor.kt+11 −9app/src/main/java/com/peachpasswords/ui/vault/TagListWidgets.kt+28 −0app/src/main/java/com/peachpasswords/ui/vault/TotpInlineEdit.kt+29 −11app/src/test/java/com/peachpasswords/ui/vault/EntryDetailScreenTest.kt+1 −1
VERSION+1 −1
@@ -1 +1 @@1-2.14.381+2.14.42

view file

app/src/main/java/com/peachpasswords/ui/vault/EditableFieldRow.kt+42 −33
@@ -9,7 +9,7 @@99  *1010  * Provides:1111  *   - EditableFieldRow      : view <-> edit toggle, underlined BasicTextField,12- *                             ✓ save / ✗ cancel, optional identity-suggest dropdown.12+ *                             ✓ save / ✗ cancel, optional inline identity list.1313  *   - EditablePasswordRow   : same, but gated behind a biometric/password unlock1414  *                             before entering edit mode (mirrors the reveal flow).1515  *@@ -29,9 +29,12 @@2929 import androidx.compose.foundation.layout.Spacer3030 import androidx.compose.foundation.layout.fillMaxWidth3131 import androidx.compose.foundation.layout.height32+import androidx.compose.foundation.layout.heightIn3233 import androidx.compose.foundation.layout.padding3334 import androidx.compose.foundation.layout.size3435 import androidx.compose.foundation.layout.width36+import androidx.compose.foundation.lazy.LazyColumn37+import androidx.compose.foundation.lazy.items3538 import androidx.compose.foundation.shape.RoundedCornerShape3639 import androidx.compose.foundation.text.BasicTextField3740 import androidx.compose.material.icons.Icons@@ -39,8 +42,6 @@3942 import androidx.compose.material.icons.outlined.Visibility4043 import androidx.compose.material.icons.outlined.VisibilityOff4144 import androidx.compose.material.icons.rounded.Close42-import androidx.compose.material3.DropdownMenu43-import androidx.compose.material3.DropdownMenuItem4445 import androidx.compose.material3.Icon4546 import androidx.compose.material3.Text4647 import androidx.compose.runtime.Composable@@ -59,7 +60,6 @@5960 import androidx.compose.ui.text.input.PasswordVisualTransformation6061 import androidx.compose.ui.text.input.VisualTransformation6162 import androidx.compose.ui.unit.dp62-import androidx.compose.ui.unit.em6363 import androidx.fragment.app.FragmentActivity6464 import com.peachpasswords.ui.components.CopyButton6565 import com.peachpasswords.ui.lock.Biometrics@@ -72,12 +72,12 @@7272  *7373  * View mode: value text + copy button (unchanged look from the existing FieldRow).7474  * Edit mode: an underlined BasicTextField with ✓ save / ✗ cancel beneath it, and75- * — when [suggestions] is non-empty — a DropdownMenu of prior values to tap-fill.75+ * — when [suggestions] is non-empty — an inline, scrollable list of prior values.7676  *7777  * @param field   logical field key passed to [onSave] (e.g. "username", "email", "notes").7878  * @param onSave  called with the trimmed draft when the user taps ✓; the caller7979  *                (EntryDetailViewModel.updateField) persists it.80- * @param suggestions identity-suggest values for USERNAME/EMAIL; empty disables the dropdown.80+ * @param suggestions identity-suggest values for USERNAME/EMAIL; empty hides the list.8181  * @param mono    mono body style for the value (false = serif body, used by notes).8282  * @param displayText rendered form of [value] for view mode; null shows [value] itself.8383  *                Notes pass their parsed HTML here (see NoteHtml.kt).@@ -92,7 +92,7 @@9292     copied: Boolean,9393     onCopy: () -> Unit,9494     onSave: (String) -> Unit,95-    suggestions: List<String> = emptyList(),95+    suggestions: List<IdentitySuggestion> = emptyList(),9696     mono: Boolean = true,9797     singleLine: Boolean = true,9898     placeholder: String = "",@@ -236,43 +236,52 @@236236     }237237 }238238 239-/** Underlined editor + suggestion dropdown + save/cancel — the shared edit body. */239+/** Underlined editor + inline scrollable suggestions + save/cancel. */240240 @Composable241241 private fun InlineEditor(242242     draft: String,243243     onDraftChange: (String) -> Unit,244244     onSave: () -> Unit,245245     onCancel: () -> Unit,246-    suggestions: List<String>,246+    suggestions: List<IdentitySuggestion>,247247     onPickSuggestion: (String) -> Unit,248248     singleLine: Boolean,249249     modifier: Modifier = Modifier,250250 ) {251-    var dropdownOpen by remember { mutableStateOf(suggestions.isNotEmpty()) }252-253251     Column(modifier) {254-        Box {255-            UnderlinedField(256-                draft = draft,257-                onDraftChange = onDraftChange,258-                singleLine = singleLine,259-                modifier = Modifier.fillMaxWidth(),260-            )261-            if (suggestions.isNotEmpty()) {262-                DropdownMenu(263-                    expanded = dropdownOpen,264-                    onDismissRequest = { dropdownOpen = false },265-                ) {266-                    suggestions.take(4).forEach { suggestion ->267-                        DropdownMenuItem(268-                            text = {269-                                Text(270-                                    suggestion,271-                                    color = peachColors.T1,272-                                    style = PeachType.labelMono.copy(letterSpacing = 0.06.em),273-                                )274-                            },275-                            onClick = { onPickSuggestion(suggestion); dropdownOpen = false },252+        UnderlinedField(253+            draft = draft,254+            onDraftChange = onDraftChange,255+            singleLine = singleLine,256+            modifier = Modifier.fillMaxWidth(),257+        )258+        if (suggestions.isNotEmpty()) {259+            Spacer(Modifier.height(6.dp))260+            LazyColumn(261+                modifier = Modifier262+                    .fillMaxWidth()263+                    .heightIn(max = 236.dp)264+                    .clip(RoundedCornerShape(8.dp))265+                    .background(peachColors.SurfaceCard),266+            ) {267+                items(suggestions, key = { it.value }) { suggestion ->268+                    Row(269+                        Modifier270+                            .fillMaxWidth()271+                            .clickable { onPickSuggestion(suggestion.value) }272+                            .padding(horizontal = 12.dp, vertical = 9.dp),273+                        verticalAlignment = Alignment.CenterVertically,274+                    ) {275+                        Text(276+                            suggestion.value,277+                            color = peachColors.T1,278+                            style = PeachType.bodyMono,279+                            modifier = Modifier.weight(1f),280+                        )281+                        Text(282+                            "${suggestion.count}×",283+                            color = peachColors.T2,284+                            style = PeachType.labelMono,276285                         )277286                     }278287                 }

view file

app/src/main/java/com/peachpasswords/ui/vault/EntryDetailScreen.kt+25 −112
@@ -1,6 +1,5 @@11 package com.peachpasswords.ui.vault22 3-import androidx.compose.animation.AnimatedVisibility43 import androidx.compose.animation.core.LinearEasing54 import androidx.compose.animation.core.RepeatMode65 import androidx.compose.animation.core.animateFloat@@ -20,7 +19,6 @@2019 import androidx.compose.foundation.layout.fillMaxSize2120 import androidx.compose.foundation.layout.fillMaxWidth2221 import androidx.compose.foundation.layout.height23-import androidx.compose.foundation.layout.offset2422 import androidx.compose.foundation.layout.padding2523 import androidx.compose.foundation.layout.navigationBarsPadding2624 import androidx.compose.foundation.layout.size@@ -71,7 +69,6 @@7169 import com.peachpasswords.ui.components.CopyButton7270 import com.peachpasswords.ui.components.PeachEmptyOrEndState7371 import com.peachpasswords.ui.components.SecureFlagEffect74-import com.peachpasswords.ui.components.TOTPRing7572 import com.peachpasswords.ui.lock.Biometrics7673 import com.peachpasswords.ui.theme.LocalPeachAccent7774 import com.peachpasswords.ui.theme.LocalReduceMotion@@ -103,19 +100,15 @@103100 104101     val entry by vm.entry.collectAsState()105102     val activeEntries by vm.activeEntries.collectAsState()106-    val identitySuggestions by vm.identitySuggestions.collectAsState()107-    val allTags by vm.allTags.collectAsState()108-    val isPro by vm.isPro.collectAsState()103+    val usernameSuggestions by vm.usernameSuggestions.collectAsState()104+    val emailSuggestions by vm.emailSuggestions.collectAsState()109105     val cvcRevealed by vm.cvcRevealed.collectAsState()110106     var cardRevealed by remember { mutableStateOf(false) }111107     val copiedField by vm.copiedField.collectAsState()112-    val canaryMessage by vm.canaryMessage.collectAsState()113-    val canaryTesting by vm.canaryTesting.collectAsState()114108     val context = LocalContext.current115109     val accent = LocalPeachAccent.current116110     var confirmingDelete by remember { mutableStateOf(false) }117111     var generatorOpen by remember { mutableStateOf(false) }118-    var historyExpanded by remember { mutableStateOf(false) }119112     var urlEditing by remember { mutableStateOf(false) }120113 121114     val e = entry@@ -187,31 +180,32 @@187180                     HomographWarning()188181                 }189182 190-                // Hero — ghost Garamond watermark behind the entry name. The full191-                // title is drawn (not just its first letter); a long title overflows192-                // and clips naturally, which is the intended watermark behavior.193-                Box(Modifier.fillMaxWidth().height(124.dp)) {194-                    Text(195-                        text = ghostWatermarkText(e.title),196-                        style = PeachType.ghostMark.copy(fontSize = 150.sp),197-                        color = peachColors.T0.copy(alpha = 0.03f),198-                        maxLines = 1,199-                        softWrap = false,200-                        overflow = TextOverflow.Clip,201-                        modifier = Modifier202-                            .align(Alignment.TopCenter)203-                            .wrapContentHeight(unbounded = true)204-                            .offset(y = (-16).dp),205-                    )206-                    Column(Modifier.fillMaxWidth().padding(top = 18.dp)) {183+                // Keep the title clean, then give the ghost mark its own lower184+                // zone so the watermark never sits behind the white entry name.185+                Column(Modifier.fillMaxWidth().padding(top = 18.dp)) {207186                         EditableTitleHero(208187                             title = e.title,209188                             onSave = { vm.updateField("title", it) },210189                         )190+                    Spacer(Modifier.height(PeachSpacing.sm))191+                    Box(Modifier.fillMaxWidth().height(100.dp)) {192+                        Text(193+                            text = ghostWatermarkText(e.title),194+                            style = PeachType.ghostMark.copy(fontSize = 150.sp),195+                            color = peachColors.T0.copy(alpha = 0.03f),196+                            maxLines = 1,197+                            softWrap = false,198+                            overflow = TextOverflow.Clip,199+                            modifier = Modifier200+                                .align(Alignment.TopStart)201+                                .wrapContentHeight(unbounded = true),202+                        )211203                     }212204                 }213205                 Spacer(Modifier.height(PeachSpacing.sm))214206 207+                EntryTagCards(e.tags.orEmpty())208+215209                 if (e.isCard()) {216210                     CardDetailFields(217211                         entry = e,@@ -231,8 +225,8 @@231225                         copyButton = { copied, onCopy -> CopyButton(copied, onCopy) },232226                     )233227                 } else {234-                    var showUsernameField by remember(e.id) { mutableStateOf(!e.username.isNullOrBlank()) }235-                    var showEmailField by remember(e.id) { mutableStateOf(!e.email.isNullOrBlank()) }228+                    val showUsernameField = !e.username.isNullOrBlank()229+                    val showEmailField = !e.email.isNullOrBlank()236230                     val hasPassword = !e.password.isNullOrBlank()237231                     if (!e.isNote()) {238232                         CredentialStack {@@ -255,7 +249,7 @@255249                                     copied = copiedField == "username",256250                                     onCopy = { vm.copy("username", e.username) },257251                                     onSave = { vm.updateField("username", it) },258-                                    suggestions = identitySuggestions,252+                                    suggestions = usernameSuggestions,259253                                     placeholder = "add username",260254                                     stacked = true,261255                                     divider = showEmailField || hasPassword,@@ -267,7 +261,7 @@267261                                     copied = copiedField == "email",268262                                     onCopy = { vm.copy("email", e.email) },269263                                     onSave = { vm.updateField("email", it) },270-                                    suggestions = identitySuggestions,264+                                    suggestions = emailSuggestions,271265                                     placeholder = "add email",272266                                     stacked = true,273267                                     divider = hasPassword,@@ -286,28 +280,6 @@286280                             }287281                         }288282                     }289-                    if (!showUsernameField || !showEmailField) {290-                        Row(horizontalArrangement = Arrangement.spacedBy(16.dp)) {291-                            if (!showUsernameField) {292-                                Text(293-                                    "+ add username", color = accent, style = PeachType.labelMono,294-                                    modifier = Modifier295-                                        .clip(RoundedCornerShape(6.dp))296-                                        .clickable { showUsernameField = true }297-                                        .padding(vertical = 6.dp),298-                                )299-                            }300-                            if (!showEmailField) {301-                                Text(302-                                    "+ add email", color = accent, style = PeachType.labelMono,303-                                    modifier = Modifier304-                                        .clip(RoundedCornerShape(6.dp))305-                                        .clickable { showEmailField = true }306-                                        .padding(vertical = 6.dp),307-                                )308-                            }309-                        }310-                    }311283                     AdditionalUrlRows(312284                         urls = e.additionalUrls.orEmpty(),313285                         copiedField = copiedField,@@ -319,25 +291,6 @@319291                     if (e.hasPasskey()) {320292                         PasskeyDetailSection(e)321293                     }322-                    // Always show the history affordance (browser parity) — visible323-                    // even before any password change, expanding to an empty-state324-                    // explainer so the feature is discoverable rather than hidden325-                    // until history already exists.326-                    val history = e.passwordHistory.orEmpty()327-                    PasswordHistoryToggle(328-                        count = history.size,329-                        expanded = historyExpanded,330-                        onToggle = { historyExpanded = !historyExpanded },331-                    )332-                    AnimatedVisibility(visible = historyExpanded) {333-                        Column(Modifier.padding(top = 8.dp, bottom = 6.dp)) {334-                            PasswordHistoryList(335-                                history = history,336-                                onRestore = { oldPw -> vm.restorePassword(e.id, oldPw) },337-                                onCopy = { oldPw -> vm.copy("password", oldPw) },338-                            )339-                        }340-                    }341294                     if (e.hasTOTP()) {342295                         Spacer(Modifier.height(4.dp))343296                         TotpBandEditable(@@ -373,46 +326,6 @@373326                         }374327                     },375328                 )376-                Spacer(Modifier.height(PeachSpacing.xxl + PeachSpacing.xxl))377-                DetailTagRow(378-                    tags = e.tags.orEmpty(),379-                    allTags = allTags,380-                    onAddTag = vm::addTag,381-                    onRemoveTag = vm::removeTag,382-                )383-                if (!e.isNote() && !e.isCard()) {384-                    // Collapsed by default (mirrors the browser's "+ ADVANCED"),385-                    // unless a canary is already configured for this entry — every386-                    // entry showing an "Off" canary block permanently was clutter387-                    // most people never touch.388-                    var advancedExpanded by remember(e.id) { mutableStateOf(e.canary != null) }389-                    if (advancedExpanded) {390-                        CanaryDetailSection(391-                            entryId = e.id,392-                            modifiedAt = e.modifiedAt,393-                            canary = e.canary,394-                            isPro = isPro,395-                            message = canaryMessage,396-                            testing = canaryTesting,397-                            onSave = vm::saveCanary,398-                            onTest = vm::testCanary,399-                        )400-                    } else {401-                        FieldShell("advanced") {402-                            Text(403-                                "Canary alert and protection",404-                                color = peachColors.T2,405-                                style = PeachType.bodyMono,406-                                modifier = Modifier.weight(1f),407-                            )408-                            Text(409-                                "open", color = LocalPeachAccent.current, style = PeachType.labelMono,410-                                modifier = Modifier.clickable { advancedExpanded = true }.padding(8.dp),411-                            )412-                        }413-                    }414-                }415-416329                 Spacer(Modifier.height(PeachSpacing.xxl))417330             }418331 @@ -596,7 +509,7 @@596509 }597510 598511 @Composable599-private fun CanaryDetailSection(512+internal fun CanaryEditSection(600513     entryId: String,601514     modifiedAt: Long,602515     canary: com.peachpasswords.vault.Canary?,

view file

app/src/main/java/com/peachpasswords/ui/vault/EntryDetailViewModel.kt+8 −3
@@ -70,10 +70,15 @@7070             .map { it.filter(::isNavigable) }7171             .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), vault.getActiveEntries().filter(::isNavigable))7272 73-    /** Distinct prior username/email values for the identity-suggest dropdown. */74-    val identitySuggestions: StateFlow<List<String>> =73+    /** Prior identities, classified by @ and ranked by frequency for inline editing. */74+    val usernameSuggestions: StateFlow<List<IdentitySuggestion>> =7575         vault.entries76-            .map { vault.getDistinctFieldValues("username").take(4) }76+            .map { identitySuggestionCounts(it, IdentityField.Username) }77+            .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptyList())78+79+    val emailSuggestions: StateFlow<List<IdentitySuggestion>> =80+        vault.entries81+            .map { identitySuggestionCounts(it, IdentityField.Email) }7782             .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptyList())7883 7984     val allTags: StateFlow<List<String>> =

view file

app/src/main/java/com/peachpasswords/ui/vault/EntryEditScreen.kt+10 −0
@@ -206,6 +206,16 @@206206                             onAddTotp = vm::revealTotp,207207                             onAddNotes = vm::revealNotes,208208                         )209+                        CanaryEditSection(210+                            entryId = id ?: "new",211+                            modifiedAt = f.canary?.lastPinged ?: 0L,212+                            canary = f.canary,213+                            isPro = vm.isPro,214+                            message = f.canaryMessage,215+                            testing = f.canaryTesting,216+                            onSave = vm::setCanary,217+                            onTest = vm::testCanary,218+                        )209219                     }210220                     TagChipEditor(211221                         tags = f.tags,

view file

app/src/main/java/com/peachpasswords/ui/vault/EntryEditViewModel.kt+90 −9
@@ -5,8 +5,10 @@55 import androidx.lifecycle.SavedStateHandle66 import androidx.lifecycle.ViewModel77 import androidx.lifecycle.viewModelScope8+import com.peachpasswords.license.LicenseRepository89 import com.peachpasswords.vault.PasswordGenerator910 import com.peachpasswords.vault.PasswordHistoryItem11+import com.peachpasswords.vault.Canary1012 import com.peachpasswords.vault.VaultEntry1113 import com.peachpasswords.vault.VaultRepository1214 import dagger.hilt.android.lifecycle.HiltViewModel@@ -15,7 +17,13 @@1517 import kotlinx.coroutines.flow.StateFlow1618 import kotlinx.coroutines.flow.asStateFlow1719 import kotlinx.coroutines.flow.update20+import kotlinx.coroutines.Dispatchers1821 import kotlinx.coroutines.launch22+import kotlinx.coroutines.withContext23+import okhttp3.MediaType.Companion.toMediaType24+import okhttp3.OkHttpClient25+import okhttp3.Request26+import okhttp3.RequestBody.Companion.toRequestBody1927 import javax.inject.Inject2028 2129 data class EditForm(@@ -36,6 +44,9 @@3644     val showCvc: Boolean = false,3745     val cardNetwork: String = "unknown",3846     val tags: List<String> = emptyList(),47+    val canary: Canary? = null,48+    val canaryMessage: String? = null,49+    val canaryTesting: Boolean = false,3950     val showPassword: Boolean = false,4051     val generatorOpen: Boolean = false,4152     val genCopied: Boolean = false,@@ -56,11 +67,14 @@5667 class EntryEditViewModel @Inject constructor(5768     private val vault: VaultRepository,5869     @ApplicationContext private val appContext: Context,70+    private val license: LicenseRepository,5971     savedStateHandle: SavedStateHandle,6072 ) : ViewModel() {6173     private val id: String? = savedStateHandle.get<String>("id")?.takeIf { it.isNotBlank() }6274     val isNew: Boolean = id == null6375     private val existing: VaultEntry? = id?.let { vault.getEntry(it) }76+    val isPro: Boolean = license.isProActive()77+    private val canaryClient by lazy { OkHttpClient() }6478 6579     /**6680      * A note authored in the browser is HTML; the form edits its markup form and@@ -97,6 +111,7 @@97111                 cvc = it.cvc.orEmpty(),98112                 cardNetwork = it.cardNetwork ?: CardFieldUtils.detectNetwork(it.cardNumber.orEmpty()),99113                 tags = it.tags.orEmpty(),114+                canary = it.canary,100115                 history = it.passwordHistory.orEmpty(),101116                 showTotp = !it.totp.isNullOrBlank(),102117                 showNotes = !it.notes.isNullOrBlank(),@@ -171,6 +186,49 @@171186     }172187     fun removeTag(tag: String) = _form.update { it.copy(tags = it.tags - tag) }173188 189+    fun setCanary(enabled: Boolean, webhookUrl: String, label: String) {190+        if (enabled && existing?.canary == null && !license.isProActive()) {191+            _form.update { it.copy(canaryMessage = "Canary entries require Peach Pro") }192+            return193+        }194+        _form.update { form ->195+            form.copy(196+                canary = if (enabled) {197+                    Canary(198+                        webhookUrl = webhookUrl.trim(),199+                        label = label.trim().ifBlank { null },200+                        lastPinged = form.canary?.lastPinged,201+                        pingCount = form.canary?.pingCount,202+                    )203+                } else {204+                    null205+                },206+                canaryMessage = null,207+            )208+        }209+    }210+211+    fun testCanary(webhookUrl: String) {212+        val url = webhookUrl.trim()213+        if (!isAllowedCanaryWebhookUrl(url)) {214+            _form.update { it.copy(canaryMessage = "enter a public HTTPS webhook URL") }215+            return216+        }217+        viewModelScope.launch {218+            _form.update { it.copy(canaryTesting = true, canaryMessage = null) }219+            val result = runCatching {220+                withContext(Dispatchers.IO) {221+                    val body = """222+                        {"event":"peach_canary_test","entry":${canaryJsonString(_form.value.title)},"trigger":"test","timestamp":${canaryJsonString(java.time.Instant.now().toString())},"source":"peach-android"}223+                    """.trimIndent().toRequestBody("application/json".toMediaType())224+                    val request = Request.Builder().url(url).post(body).build()225+                    canaryClient.newCall(request).execute().use { it.isSuccessful }226+                }227+            }.getOrDefault(false)228+            _form.update { it.copy(canaryTesting = false, canaryMessage = if (result) "sent" else "failed - check URL") }229+        }230+    }231+174232     fun toggleShowPassword() = _form.update { it.copy(showPassword = !it.showPassword) }175233     fun toggleGenerator() = _form.update { form ->176234         val opening = !form.generatorOpen@@ -221,6 +279,16 @@221279             _form.update { it.copy(error = "Cardholder name and card number are required") }222280             return223281         }282+        if (f.canary != null) {283+            if (existing?.canary == null && !license.isProActive()) {284+                _form.update { it.copy(error = "Canary entries require Peach Pro") }285+                return286+            }287+            if (!isAllowedCanaryWebhookUrl(f.canary.webhookUrl)) {288+                _form.update { it.copy(error = "enter a public HTTPS webhook URL") }289+                return290+            }291+        }224292         _form.update { it.copy(saving = true, error = null) }225293         viewModelScope.launch {226294             runCatching {@@ -245,6 +313,7 @@245313                             cvc = f.cvc.ifBlank { null },246314                             cardNetwork = f.cardNetwork.takeIf { it != "unknown" },247315                             tags = f.tags.ifEmpty { null },316+                            canary = f.canary,248317                             createdAt = 0, modifiedAt = 0,249318                         ),250319                     )@@ -269,6 +338,7 @@269338                             cvc = f.cvc.ifBlank { null },270339                             cardNetwork = f.cardNetwork.takeIf { it != "unknown" },271340                             tags = f.tags.ifEmpty { null },341+                            canary = f.canary,272342                         ),273343                     )274344                 }@@ -278,6 +348,13 @@278348     }279349 }280350 351+private fun canaryJsonString(value: String): String =352+    "\"" + value353+        .replace("\\", "\\\\")354+        .replace("\"", "\\\"")355+        .replace("\n", "\\n")356+        .replace("\r", "\\r") + "\""357+281358 internal const val BROWSER_ADD_GENERATOR_LENGTH = 64282359 283360 private fun EditForm.withGeneratedPassword(): EditForm =@@ -311,7 +388,16 @@311388 312389 internal enum class IdentityField { Username, Email }313390 391+data class IdentitySuggestion(val value: String, val count: Int)392+314393 internal fun identitySuggestionsFor(entries: List<VaultEntry>, field: IdentityField): List<String> {394+    return identitySuggestionCounts(entries, field).map { it.value }395+}396+397+internal fun identitySuggestionCounts(398+    entries: List<VaultEntry>,399+    field: IdentityField,400+): List<IdentitySuggestion> {315401     val buckets = linkedMapOf<String, Pair<String, Int>>()316402 317403     fun add(value: String) {@@ -323,19 +409,14 @@323409     }324410 325411     entries.filter { it.deletedAt == null }.forEach { entry ->326-        val username = entry.username?.trim().orEmpty()327-        val email = entry.email?.trim().orEmpty()328-        when (field) {329-            IdentityField.Username -> if (username.isNotEmpty() && !username.contains("@")) add(username)330-            IdentityField.Email -> {331-                if (username.contains("@")) add(username)332-                if (email.isNotEmpty()) add(email)333-            }412+        listOfNotNull(entry.username, entry.email).forEach { value ->413+            val trimmed = value.trim()414+            if (trimmed.isNotEmpty() && (trimmed.contains("@") == (field == IdentityField.Email))) add(trimmed)334415         }335416     }336417 337418     return buckets.values338419         .sortedWith(compareByDescending<Pair<String, Int>> { it.second }.thenBy { it.first.lowercase() })339-        .map { it.first }420+        .map { IdentitySuggestion(it.first, it.second) }340421         .take(8)341422 }

view file

app/src/main/java/com/peachpasswords/ui/vault/TagChipEditor.kt+11 −9
@@ -105,13 +105,15 @@105105             if (!atLimit) {106106                 Box(107107                     Modifier108-                        .widthIn(min = 90.dp)109-                        .border(1.dp, peachColors.T3, RoundedCornerShape(6.dp))110-                        .padding(horizontal = 10.dp, vertical = 6.dp),108+                        .widthIn(min = 112.dp)109+                        .clip(RoundedCornerShape(8.dp))110+                        .background(accent.copy(alpha = 0.10f))111+                        .border(1.dp, accent.copy(alpha = 0.28f), RoundedCornerShape(8.dp))112+                        .padding(horizontal = 11.dp, vertical = 9.dp),111113                     contentAlignment = Alignment.CenterStart,112114                 ) {113115                     if (draft.isEmpty()) {114-                        Text("add a tag…", color = peachColors.T2, style = PeachType.bodyMono)116+                        Text("add a tag…", color = accent.copy(alpha = 0.72f), style = PeachType.bodyMono)115117                     }116118                     BasicTextField(117119                         value = draft,@@ -163,13 +165,13 @@163165     val accent = LocalPeachAccent.current164166     Row(165167         modifier = Modifier166-            .clip(RoundedCornerShape(6.dp))167-            .background(accent.copy(alpha = 0.12f))168-            .border(1.dp, accent.copy(alpha = 0.28f), RoundedCornerShape(6.dp))169-            .padding(start = 8.dp, end = 4.dp, top = 4.dp, bottom = 4.dp),168+            .clip(RoundedCornerShape(8.dp))169+            .background(accent.copy(alpha = 0.16f))170+            .border(1.dp, accent.copy(alpha = 0.34f), RoundedCornerShape(8.dp))171+            .padding(start = 11.dp, end = 5.dp, top = 8.dp, bottom = 8.dp),170172         verticalAlignment = Alignment.CenterVertically,171173     ) {172-        Text(label, color = accent, style = PeachType.labelMono)174+        Text(label, color = accent, style = PeachType.bodyMono)173175         Spacer(Modifier.width(4.dp))174176         Text(175177             "×",

view file

app/src/main/java/com/peachpasswords/ui/vault/TagListWidgets.kt+28 −0
@@ -4,6 +4,8 @@44 import androidx.compose.foundation.border55 import androidx.compose.foundation.clickable66 import androidx.compose.foundation.layout.Arrangement7+import androidx.compose.foundation.layout.ExperimentalLayoutApi8+import androidx.compose.foundation.layout.FlowRow79 import androidx.compose.foundation.layout.Row810 import androidx.compose.foundation.layout.fillMaxWidth911 import androidx.compose.foundation.layout.padding@@ -90,6 +92,32 @@9092                     .clip(RoundedCornerShape(4.dp))9193                     .border(0.5.dp, peachColors.T3, RoundedCornerShape(4.dp))9294                     .padding(horizontal = 5.dp, vertical = 1.dp),95+            )96+        }97+    }98+}99+100+/** Larger read-only tag cards shown above an Entry's credential card. */101+@OptIn(ExperimentalLayoutApi::class)102+@Composable103+internal fun EntryTagCards(tags: List<String>) {104+    if (tags.isEmpty()) return105+    val accent = LocalPeachAccent.current106+    FlowRow(107+        modifier = Modifier.fillMaxWidth().padding(top = 4.dp, bottom = 10.dp),108+        horizontalArrangement = Arrangement.spacedBy(8.dp),109+        verticalArrangement = Arrangement.spacedBy(8.dp),110+    ) {111+        tags.forEach { tag ->112+            Text(113+                text = tag,114+                color = accent,115+                style = PeachType.bodyMono,116+                modifier = Modifier117+                    .clip(RoundedCornerShape(8.dp))118+                    .background(accent.copy(alpha = 0.16f))119+                    .border(1.dp, accent.copy(alpha = 0.34f), RoundedCornerShape(8.dp))120+                    .padding(horizontal = 11.dp, vertical = 8.dp),93121             )94122         }95123     }

view file

app/src/main/java/com/peachpasswords/ui/vault/TotpInlineEdit.kt+29 −11
@@ -26,10 +26,10 @@2626 2727 import androidx.compose.animation.core.RepeatMode2828 import androidx.compose.animation.core.animateFloat29+import androidx.compose.animation.core.animateFloatAsState2930 import androidx.compose.animation.core.infiniteRepeatable3031 import androidx.compose.animation.core.rememberInfiniteTransition3132 import androidx.compose.animation.core.tween32-import androidx.compose.foundation.background3333 import androidx.compose.foundation.border3434 import androidx.compose.foundation.clickable3535 import androidx.compose.foundation.gestures.detectTapGestures@@ -60,6 +60,7 @@6060 import androidx.compose.ui.draw.clip6161 import androidx.compose.ui.draw.drawBehind6262 import androidx.compose.ui.geometry.Offset63+import androidx.compose.ui.geometry.Size6364 import androidx.compose.ui.graphics.SolidColor6465 import androidx.compose.ui.graphics.graphicsLayer6566 import androidx.compose.ui.input.pointer.pointerInput@@ -68,7 +69,6 @@6869 import androidx.compose.ui.unit.dp6970 import androidx.compose.ui.unit.em7071 import com.peachpasswords.ui.components.CopyButton71-import com.peachpasswords.ui.components.TOTPRing7272 import com.peachpasswords.ui.theme.LocalPeachAccent7373 import com.peachpasswords.ui.theme.LocalReduceMotion7474 import com.peachpasswords.ui.theme.peachColors@@ -78,9 +78,9 @@7878 /**7979  * Accent-tinted TOTP band with double-tap-to-edit of the underlying 2FA secret.8080  *81- * View mode is visually identical to the existing private `TotpBand`: large code,82- * mono countdown, sweep ring, copy button. A 14dp pencil sits beside the code, and83- * a double-tap anywhere on the code/band enters edit mode.81+ * View mode is a liquid countdown card: the accent background drains right-to-left82+ * over the 30-second period and refills left-to-right when the code resets. A 14dp83+ * pencil sits beside the code, and a double-tap anywhere on the code/band edits it.8484  *8585  * Edit mode shows a masked [BasicTextField] (eye toggle to reveal) seeded with the8686  * raw `totp` field (a base32 secret or an `otpauth://` URI), labelled "2FA SECRET",@@ -98,6 +98,7 @@9898  * @param onCopy    copies the live code (NOT the secret) — wire to `vm::copyTotp`.9999  * @param onSaveSecret called with the trimmed, validated new secret on ✓ save.100100  */101+@Suppress("ProduceStateDoesNotAssignValue")101102 @Composable102103 fun TotpBandEditable(103104     totpField: String,@@ -107,16 +108,35 @@107108 ) {108109     val accent = LocalPeachAccent.current109110     val reduceMotion = LocalReduceMotion.current111+    val surfaceCard = peachColors.SurfaceCard112+    val liquidFraction by produceState(1f, totpField) {113+        while (true) {114+            value = 1f - TOTPGenerator.periodProgress()115+            kotlinx.coroutines.delay(100)116+        }117+    }118+    val animatedLiquidFraction by animateFloatAsState(119+        targetValue = liquidFraction,120+        animationSpec = tween(if (liquidFraction > 0.9f) 450 else 120),121+        label = "totp-liquid",122+    )110123 111124     var editing by remember(totpField) { mutableStateOf(false) }125+    val shape = RoundedCornerShape(8.dp)112126 113127     Box(114128         Modifier115129             .fillMaxWidth()116130             .padding(vertical = 10.dp)117-            .clip(RoundedCornerShape(8.dp))118-            .background(accent.copy(alpha = 0.08f))119-            .border(1.dp, accent.copy(alpha = 0.20f), RoundedCornerShape(8.dp))131+            .clip(shape)132+            .drawBehind {133+                drawRect(surfaceCard)134+                drawRect(135+                    color = accent.copy(alpha = 0.16f),136+                    size = Size(size.width * animatedLiquidFraction, size.height),137+                )138+            }139+            .border(1.dp, accent.copy(alpha = 0.20f), shape)120140             .padding(horizontal = 14.dp, vertical = 10.dp),121141     ) {122142         if (!editing) {@@ -138,7 +158,7 @@138158     }139159 }140160 141-/** Read-only band content: code, countdown, ring, pencil, copy — plus double-tap-to-edit. */161+/** Read-only band content: code, countdown, pencil, copy — plus double-tap-to-edit. */142162 // produceState assigns `value` inside a while(true) loop — lint can't trace it statically.143163 @Suppress("ProduceStateDoesNotAssignValue")144164 @Composable@@ -201,8 +221,6 @@201221         }202222         Text("${remaining}s", color = peachColors.T2, style = PeachType.totpCountdown)203223         Spacer(Modifier.width(12.dp))204-        TOTPRing(size = 28.dp)205-        Spacer(Modifier.width(8.dp))206224         CopyButton(copied, onCopy)207225     }208226 }

view file

app/src/test/java/com/peachpasswords/ui/vault/EntryDetailScreenTest.kt+1 −1
@@ -48,7 +48,7 @@4848 4949     private val secret = "JBSWY3DPEHPK3PXP"5050 51-    /** The screen pulses the TOTP ring in its final 5 seconds. */51+    /** The screen highlights the TOTP code in its final 5 seconds. */5252     private fun isHot(nowMillis: Long) = TOTPGenerator.remainingSeconds(nowMillis) <= 55353 5454     @Test fun totp_code_is_six_digits() {

view file