b9c49cac9cf8
fix: Refine Entry detail editing and card layout (v2.14.42)
- time
- 2026-08-14 21:01 UTC · 2 months ago
- machine
- koh2
- parent
- a67c56557afe
↓ peach-android-b9c49cac.tar.gzatlas at this save
files changed10 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 −1app/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 }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?,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>> =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,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 }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 "×",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 }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 }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() {