1b4fede31c1d
feat: Recompose entry surfaces with design primitives (v2.3.62)
- time
- 2026-07-08 20:55 UTC · 3 months ago
- machine
- koh2
- parent
- f487da4e7e23
↓ peach-android-1b4fede3.tar.gzatlas at this save
files changed4 files
app/build.gradle+2 −2app/src/main/java/com/peachpasswords/ui/components/PeachTopBar.kt+3 −0app/src/main/java/com/peachpasswords/ui/vault/EntryDetailScreen.kt+71 −45app/src/main/java/com/peachpasswords/ui/vault/EntryEditScreen.kt+155 −123app/build.gradle+2 −2
@@ -16,8 +16,8 @@1616 applicationId "com.peachpasswords"1717 minSdk 261818 targetSdk 3519- versionCode 23610020- versionName "2.3.61"19+ versionCode 23620020+ versionName "2.3.62"2121 2222 testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"2323 vectorDrawables {app/src/main/java/com/peachpasswords/ui/components/PeachTopBar.kt+3 −0
@@ -14,6 +14,7 @@1414 import androidx.compose.runtime.Composable1515 import androidx.compose.ui.Alignment1616 import androidx.compose.ui.Modifier17+import androidx.compose.ui.text.style.TextOverflow1718 import androidx.compose.ui.unit.dp1819 import com.peachpasswords.ui.theme.PeachSpacing1920 import com.peachpasswords.ui.theme.PeachType@@ -47,6 +48,8 @@4748 text = title,4849 color = peachColors.T0,4950 style = PeachType.wordmark,51+ maxLines = 1,52+ overflow = TextOverflow.Ellipsis,5053 modifier = Modifier.weight(1f),5154 )5255 if (actions != null) {app/src/main/java/com/peachpasswords/ui/vault/EntryDetailScreen.kt+71 −45
@@ -76,10 +76,13 @@7676 import com.peachpasswords.ui.components.CharEnterText7777 import com.peachpasswords.ui.components.CopyButton7878 import com.peachpasswords.ui.components.FaviconInitials79+import com.peachpasswords.ui.components.PeachEmptyOrEndState80+import com.peachpasswords.ui.components.PeachTopBar7981 import com.peachpasswords.ui.components.TOTPRing8082 import com.peachpasswords.ui.lock.Biometrics8183 import com.peachpasswords.ui.theme.LocalPeachAccent8284 import com.peachpasswords.ui.theme.LocalReduceMotion85+import com.peachpasswords.ui.theme.PeachSpacing8386 import com.peachpasswords.ui.theme.peachColors8487 import com.peachpasswords.ui.theme.PeachType8588 import com.peachpasswords.ui.theme.ShellBackground@@ -128,52 +131,24 @@128131 Modifier.fillMaxSize()129132 .padding(bottom = 100.dp)130133 .verticalScroll(rememberScrollState())131- .padding(horizontal = 24.dp),134+ .padding(horizontal = PeachSpacing.xl),132135 ) {133- // Header: back · favicon+domain (or delete confirm) · trash134- Row(135- Modifier.fillMaxWidth().height(56.dp).padding(horizontal = 8.dp),136- verticalAlignment = Alignment.CenterVertically,137- ) {138- Icon(139- Icons.AutoMirrored.Rounded.ArrowBack, contentDescription = "back", tint = peachColors.T0,140- modifier = Modifier.size(40.dp).clip(CircleShape).clickable(onClick = onBack).padding(8.dp),141- )142- Box(Modifier.weight(1f), contentAlignment = Alignment.Center) {143- when {144- e == null -> {}145- confirmingDelete -> Text(146- "delete “${e.title}”?",147- color = peachColors.Breach,148- style = PeachType.labelMono.copy(letterSpacing = 0.08.em),149- )150- else -> Row(151- verticalAlignment = Alignment.CenterVertically,152- horizontalArrangement = Arrangement.spacedBy(8.dp),153- ) {154- FaviconInitials(source = e.url ?: e.title, size = 16.dp)155- val domain = remember(e.url, e.title) { e.displayDomain() }156- if (domain.isNotEmpty()) {157- Text(domain, color = peachColors.T2, style = TextStyle(fontFamily = PeachType.Mono, fontSize = 9.sp))158- }159- }160- }161- }162- if (e != null) {163- Icon(164- Icons.Outlined.DeleteOutline, contentDescription = "delete",165- tint = if (confirmingDelete) peachColors.Breach else peachColors.T1,166- modifier = Modifier.size(40.dp).clip(CircleShape)167- .clickable { if (confirmingDelete) vm.delete(onDone = onBack) else confirmingDelete = true }168- .padding(8.dp),169- )170- }171- }136+ EntryDetailTopBar(137+ entry = e,138+ confirmingDelete = confirmingDelete,139+ onBack = onBack,140+ onDelete = {141+ if (confirmingDelete) vm.delete(onDone = onBack) else confirmingDelete = true142+ },143+ )144+ Spacer(Modifier.height(PeachSpacing.lg))172145 173146 if (e == null) {174- Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {175- Text("entry not found", color = peachColors.T1, style = PeachType.bodyMono)176- }147+ PeachEmptyOrEndState(148+ message = "entry not found",149+ detail = "It may have been moved or deleted.",150+ modifier = Modifier.padding(top = PeachSpacing.xxl),151+ )177152 return@Column178153 }179154 @@ -225,7 +200,7 @@225200 )226201 }227202 }228- Spacer(Modifier.height(20.dp))203+ Spacer(Modifier.height(PeachSpacing.lg))229204 230205 if (e.isCard()) {231206 CardDetailFields(@@ -348,7 +323,11 @@348323 )349324 }350325 351- Spacer(Modifier.height(24.dp))326+ PeachEmptyOrEndState(327+ message = "end of entry",328+ detail = "All saved fields for this item are above.",329+ modifier = Modifier.padding(top = PeachSpacing.md, bottom = PeachSpacing.lg),330+ )352331 }353332 354333 if (e != null) {@@ -372,6 +351,53 @@372351 }373352 }374353 }354+}355+356+@Composable357+private fun EntryDetailTopBar(358+ entry: com.peachpasswords.vault.VaultEntry?,359+ confirmingDelete: Boolean,360+ onBack: () -> Unit,361+ onDelete: () -> Unit,362+) {363+ val domain = remember(entry?.url, entry?.title) { entry?.displayDomain().orEmpty() }364+ PeachTopBar(365+ title = when {366+ entry == null -> "entry"367+ confirmingDelete -> "delete \"${entry.title}\"?"368+ domain.isNotEmpty() -> domain369+ else -> entry.title370+ },371+ leading = {372+ Icon(373+ Icons.AutoMirrored.Rounded.ArrowBack,374+ contentDescription = "back",375+ tint = peachColors.T0,376+ modifier = Modifier377+ .size(40.dp)378+ .clip(CircleShape)379+ .clickable(onClick = onBack)380+ .padding(PeachSpacing.sm),381+ )382+ if (entry != null && !confirmingDelete) {383+ FaviconInitials(source = entry.url ?: entry.title, size = 16.dp)384+ }385+ },386+ actions = {387+ if (entry != null) {388+ Icon(389+ Icons.Outlined.DeleteOutline,390+ contentDescription = "delete",391+ tint = if (confirmingDelete) peachColors.Breach else peachColors.T1,392+ modifier = Modifier393+ .size(40.dp)394+ .clip(CircleShape)395+ .clickable(onClick = onDelete)396+ .padding(PeachSpacing.sm),397+ )398+ }399+ },400+ )375401 }376402 377403 @Composableapp/src/main/java/com/peachpasswords/ui/vault/EntryEditScreen.kt+155 −123
@@ -34,7 +34,6 @@3434 import androidx.compose.ui.draw.clipToBounds3535 import androidx.compose.ui.graphics.Color3636 import androidx.compose.ui.text.TextStyle37-import androidx.compose.ui.text.font.FontStyle3837 import androidx.compose.ui.text.font.FontWeight3938 import androidx.compose.ui.text.input.PasswordVisualTransformation4039 import androidx.compose.ui.text.input.VisualTransformation@@ -45,13 +44,18 @@4544 import androidx.compose.runtime.collectAsState4645 import androidx.hilt.navigation.compose.hiltViewModel4746 import com.peachpasswords.ui.components.CopyButton47+import com.peachpasswords.ui.components.PeachEmptyOrEndState48+import com.peachpasswords.ui.components.PeachSegment49+import com.peachpasswords.ui.components.PeachSegmentedControl50+import com.peachpasswords.ui.components.PeachTopBar4851 import com.peachpasswords.ui.components.SecureFlagEffect4952 import com.peachpasswords.ui.theme.LocalPeachAccent50-import com.peachpasswords.ui.theme.peachColors5153 import com.peachpasswords.ui.theme.PeachMotion5254 import com.peachpasswords.ui.theme.PeachRadius55+import com.peachpasswords.ui.theme.PeachSpacing5356 import com.peachpasswords.ui.theme.PeachType5457 import com.peachpasswords.ui.theme.ShellBackground58+import com.peachpasswords.ui.theme.peachColors5559 5660 @Composable5761 fun EntryEditScreen(@@ -64,7 +68,6 @@6468 val f by vm.form.collectAsState()6569 val usernameSuggestions by vm.usernameSuggestions.collectAsState()6670 val emailSuggestions by vm.emailSuggestions.collectAsState()67- val accent = LocalPeachAccent.current6871 6972 // Ghost watermark fades in (0 → 0.03α over 300ms) once the title has any text.7073 val watermarkAlpha by androidx.compose.animation.core.animateFloatAsState(@@ -74,20 +77,22 @@7477 )7578 7679 ShellBackground {77- Column(Modifier.fillMaxSize().statusBarsPadding()) {78- Box(Modifier.fillMaxWidth().height(56.dp).padding(horizontal = 18.dp)) {79- val canSave = canSaveEditForm(f)80- Text("cancel", color = peachColors.T2, fontFamily = PeachType.Mono, fontSize = 11.sp,81- modifier = Modifier.align(Alignment.CenterStart)82- .clip(RoundedCornerShape(8.dp)).clickable(onClick = onBack).padding(8.dp))83- Text(editTitleBarLabel(vm.isNew, f.type), color = peachColors.T1,84- fontFamily = PeachType.Display, fontStyle = FontStyle.Italic, fontSize = 18.sp,85- modifier = Modifier.align(Alignment.Center))86- Text(entryEditPrimaryActionLabel(vm.isNew, f.saving), color = if (canSave && !f.saving) accent else peachColors.T2, fontFamily = PeachType.Mono, fontSize = 11.sp,87- modifier = Modifier.align(Alignment.CenterEnd)88- .clip(RoundedCornerShape(8.dp)).clickable(enabled = canSave && !f.saving) { vm.save(onBack) }.padding(8.dp))89- }80+ Column(81+ Modifier82+ .fillMaxSize()83+ .statusBarsPadding()84+ .padding(horizontal = PeachSpacing.xl),85+ ) {86+ EntryEditTopBar(87+ title = editTitleBarLabel(vm.isNew, f.type),88+ saveLabel = entryEditPrimaryActionLabel(vm.isNew, f.saving),89+ canSave = canSaveEditForm(f) && !f.saving,90+ onCancel = onBack,91+ onSave = { vm.save(onBack) },92+ )9093 94+ Spacer(Modifier.height(PeachSpacing.lg))95+9196 Box(Modifier.fillMaxSize().clipToBounds()) {9297 // Ghost PeachScript watermark behind the form (matches the detail hero):9398 // the full title, fading in as the user types.@@ -103,132 +108,153 @@103108 )104109 }105110 Column(106- Modifier.fillMaxSize().verticalScroll(rememberScrollState()).padding(horizontal = 22.dp),111+ Modifier.fillMaxSize().verticalScroll(rememberScrollState()),107112 ) {108- Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {109- listOf("login", "note", "card").forEach { t ->110- EntryTypePill(t, f.type == t) {113+ PeachSegmentedControl(114+ segments = listOf(115+ PeachSegment("login", "login"),116+ PeachSegment("note", "note"),117+ PeachSegment("card", "card"),118+ ),119+ selectedKey = f.type,120+ onSelected = { t ->111121 // A brand-new note skips the form entirely and drops straight112122 // into the full-screen notes editor (brief Fix 6 item 7).113123 if (shouldOpenNotesMode(vm.isNew, t)) vm.createNoteAndOpen(onOpenNote)114124 else vm.setType(t)125+ },126+ )127+ Spacer(Modifier.height(PeachSpacing.lg))128+129+ Field("title", f.title, vm::setTitle)130+ if (f.type == "login") {131+ Field("url", f.url, vm::setUrl)132+ AdditionalUrlsEditor(133+ urls = f.additionalUrls,134+ onChange = vm::setAdditionalUrl,135+ onAdd = vm::addAdditionalUrl,136+ onRemove = vm::removeAdditionalUrl,137+ )138+ IdentitySuggestField("username", f.username, usernameSuggestions, vm::setUsername)139+ IdentitySuggestField("email", f.email, emailSuggestions, vm::setEmail)140+ PasswordField(141+ value = f.password,142+ show = f.showPassword,143+ generatorOpen = f.generatorOpen,144+ onChange = vm::setPassword,145+ onToggleShow = vm::toggleShowPassword,146+ onToggleGenerator = vm::toggleGenerator,147+ )148+ GeneratorDrawer(149+ open = f.generatorOpen,150+ length = f.genLength,151+ upper = f.genUpper, lower = f.genLower, numbers = f.genNumbers, symbols = f.genSymbols,152+ password = f.password,153+ copied = f.genCopied,154+ onLength = vm::setGenLength,155+ onUpper = vm::setGenUpper, onLower = vm::setGenLower,156+ onNumbers = vm::setGenNumbers, onSymbols = vm::setGenSymbols,157+ onGenerate = vm::generate,158+ onCopy = vm::copyGenerated,159+ onUse = vm::useGeneratedPassword,160+ )161+ PasswordHistorySection(162+ history = f.history,163+ expanded = f.showHistory,164+ onToggle = vm::toggleHistory,165+ onRestore = vm::restorePassword,166+ onCopy = vm::copyHistoryPassword,167+ )168+ if (shouldShowLoginTotp(f.type, f.showTotp, f.totp)) {169+ Field("2FA secret (otpauth:// or base32)", f.totp, vm::setTotp)115170 }116171 }117- }118- Spacer(Modifier.height(18.dp))119-120- Field("title", f.title, vm::setTitle)121- if (f.type == "login") {122- Field("url", f.url, vm::setUrl)123- AdditionalUrlsEditor(124- urls = f.additionalUrls,125- onChange = vm::setAdditionalUrl,126- onAdd = vm::addAdditionalUrl,127- onRemove = vm::removeAdditionalUrl,172+ if (f.type == "card") {173+ CardEditFields(174+ cardNickname = f.cardNickname,175+ cardNumber = f.cardNumber,176+ cardholderName = f.cardholderName,177+ expiry = f.expiry,178+ cvc = f.cvc,179+ showCvc = f.showCvc,180+ network = f.cardNetwork,181+ onCardNickname = vm::setCardNickname,182+ onCardNumber = vm::setCardNumber,183+ onCardholder = vm::setCardholderName,184+ onExpiry = vm::setExpiry,185+ onCvc = vm::setCvc,186+ onToggleShowCvc = vm::toggleShowCvc,187+ )188+ }189+ if (f.type == "note" || shouldShowLoginNotes(f.type, f.showNotes, f.notes)) {190+ Field("notes", f.notes, vm::setNotes, singleLine = false)191+ }192+ if (f.type == "login") {193+ OptionalLoginAddLinks(194+ showTotpLink = shouldShowAddTotpLink(f.type, f.showTotp, f.totp),195+ showNotesLink = shouldShowAddNotesLink(f.type, f.showNotes, f.notes),196+ onAddTotp = vm::revealTotp,197+ onAddNotes = vm::revealNotes,198+ )199+ }200+ TagChipEditor(201+ tags = f.tags,202+ allTags = vm.allTags,203+ onAdd = vm::addTag,204+ onRemove = vm::removeTag,128205 )129- IdentitySuggestField("username", f.username, usernameSuggestions, vm::setUsername)130- IdentitySuggestField("email", f.email, emailSuggestions, vm::setEmail)131- PasswordField(132- value = f.password,133- show = f.showPassword,134- generatorOpen = f.generatorOpen,135- onChange = vm::setPassword,136- onToggleShow = vm::toggleShowPassword,137- onToggleGenerator = vm::toggleGenerator,138- )139- GeneratorDrawer(140- open = f.generatorOpen,141- length = f.genLength,142- upper = f.genUpper, lower = f.genLower, numbers = f.genNumbers, symbols = f.genSymbols,143- password = f.password,144- copied = f.genCopied,145- onLength = vm::setGenLength,146- onUpper = vm::setGenUpper, onLower = vm::setGenLower,147- onNumbers = vm::setGenNumbers, onSymbols = vm::setGenSymbols,148- onGenerate = vm::generate,149- onCopy = vm::copyGenerated,150- onUse = vm::useGeneratedPassword,151- )152- PasswordHistorySection(153- history = f.history,154- expanded = f.showHistory,155- onToggle = vm::toggleHistory,156- onRestore = vm::restorePassword,157- onCopy = vm::copyHistoryPassword,158- )159- if (shouldShowLoginTotp(f.type, f.showTotp, f.totp)) {160- Field("2FA secret (otpauth:// or base32)", f.totp, vm::setTotp)206+207+ f.error?.let {208+ Spacer(Modifier.height(PeachSpacing.sm))209+ Text(it, color = peachColors.Breach, fontFamily = PeachType.Mono, fontSize = 11.sp)161210 }162- }163- if (f.type == "card") {164- CardEditFields(165- cardNickname = f.cardNickname,166- cardNumber = f.cardNumber,167- cardholderName = f.cardholderName,168- expiry = f.expiry,169- cvc = f.cvc,170- showCvc = f.showCvc,171- network = f.cardNetwork,172- onCardNickname = vm::setCardNickname,173- onCardNumber = vm::setCardNumber,174- onCardholder = vm::setCardholderName,175- onExpiry = vm::setExpiry,176- onCvc = vm::setCvc,177- onToggleShowCvc = vm::toggleShowCvc,211+ PeachEmptyOrEndState(212+ message = editEndStateMessage(f.type),213+ detail = "Review the fields above, then save from the top bar.",214+ modifier = Modifier.padding(top = PeachSpacing.md, bottom = PeachSpacing.xl),178215 )179216 }180- if (f.type == "note" || shouldShowLoginNotes(f.type, f.showNotes, f.notes)) {181- Field("notes", f.notes, vm::setNotes, singleLine = false)182- }183- if (f.type == "login") {184- OptionalLoginAddLinks(185- showTotpLink = shouldShowAddTotpLink(f.type, f.showTotp, f.totp),186- showNotesLink = shouldShowAddNotesLink(f.type, f.showNotes, f.notes),187- onAddTotp = vm::revealTotp,188- onAddNotes = vm::revealNotes,189- )190- }191- TagChipEditor(192- tags = f.tags,193- allTags = vm.allTags,194- onAdd = vm::addTag,195- onRemove = vm::removeTag,196- )197-198- f.error?.let {199- Spacer(Modifier.height(8.dp))200- Text(it, color = peachColors.Breach, fontFamily = PeachType.Mono, fontSize = 11.sp)201- }202- Spacer(Modifier.height(40.dp))203- }204217 }205218 }206219 }207220 }208221 209222 @Composable210-private fun EntryTypePill(type: String, selected: Boolean, onClick: () -> Unit) {223+private fun EntryEditTopBar(224+ title: String,225+ saveLabel: String,226+ canSave: Boolean,227+ onCancel: () -> Unit,228+ onSave: () -> Unit,229+) {211230 val accent = LocalPeachAccent.current212- Box(213- modifier = Modifier214- .clip(RoundedCornerShape(PeachRadius.TypeTab))215- .border(216- 1.dp,217- if (selected) accent.copy(alpha = 0.5f) else peachColors.T3,218- RoundedCornerShape(PeachRadius.TypeTab),231+ PeachTopBar(232+ title = title,233+ leading = {234+ Text(235+ "cancel",236+ color = peachColors.T2,237+ fontFamily = PeachType.Mono,238+ fontSize = 11.sp,239+ modifier = Modifier240+ .clip(RoundedCornerShape(8.dp))241+ .clickable(onClick = onCancel)242+ .padding(PeachSpacing.sm),219243 )220- .background(if (selected) accent.copy(alpha = 0.08f) else Color.Transparent)221- .clickable(onClick = onClick)222- .padding(horizontal = 12.dp, vertical = 5.dp),223- ) {224- Text(225- text = type.uppercase(),226- color = if (selected) accent else peachColors.T2,227- fontFamily = PeachType.Mono,228- fontSize = 9.sp,229- letterSpacing = 0.08.em,230- )231- }244+ },245+ actions = {246+ Text(247+ saveLabel,248+ color = if (canSave) accent else peachColors.T2,249+ fontFamily = PeachType.Mono,250+ fontSize = 11.sp,251+ modifier = Modifier252+ .clip(RoundedCornerShape(8.dp))253+ .clickable(enabled = canSave, onClick = onSave)254+ .padding(PeachSpacing.sm),255+ )256+ },257+ )232258 }233259 234260 /** Centered title-bar label: "new entry" / "new note" / "new card", or "edit". */@@ -256,6 +282,12 @@256282 257283 /** Target alpha for the edit-screen ghost watermark — visible only once a title exists. */258284 internal fun editGhostWatermarkTarget(title: String): Float = if (title.isNotBlank()) 0.03f else 0f285+286+internal fun editEndStateMessage(type: String): String = when (type) {287+ "card" -> "card details ready"288+ "note" -> "note details ready"289+ else -> "login details ready"290+}259291 260292 internal fun shouldShowLoginTotp(type: String, revealed: Boolean, totp: String): Boolean =261293 type == "login" && (revealed || totp.isNotBlank())