1package com.peachpasswords.ui.vault23import android.content.Context4import android.net.Uri5import androidx.activity.compose.rememberLauncherForActivityResult6import androidx.activity.result.contract.ActivityResultContracts7import androidx.compose.foundation.background8import androidx.compose.foundation.border9import androidx.compose.foundation.clickable10import androidx.compose.foundation.layout.Arrangement11import androidx.compose.foundation.layout.Box12import androidx.compose.foundation.layout.Column13import androidx.compose.foundation.layout.Row14import androidx.compose.foundation.layout.Spacer15import androidx.compose.foundation.layout.fillMaxSize16import androidx.compose.foundation.layout.fillMaxWidth17import androidx.compose.foundation.layout.heightIn18import androidx.compose.foundation.layout.padding19import androidx.compose.foundation.layout.size20import androidx.compose.foundation.layout.systemBarsPadding21import androidx.compose.foundation.layout.width22import androidx.compose.foundation.rememberScrollState23import androidx.compose.foundation.shape.RoundedCornerShape24import androidx.compose.foundation.text.BasicTextField25import androidx.compose.foundation.verticalScroll26import androidx.compose.material.icons.Icons27import androidx.compose.material.icons.automirrored.rounded.ArrowBack28import androidx.compose.material3.Icon29import androidx.compose.material3.LinearProgressIndicator30import androidx.compose.material3.Text31import androidx.compose.runtime.Composable32import androidx.compose.runtime.LaunchedEffect33import androidx.compose.runtime.collectAsState34import androidx.compose.runtime.getValue35import androidx.compose.runtime.mutableStateOf36import androidx.compose.runtime.remember37import androidx.compose.runtime.setValue38import androidx.compose.ui.Alignment39import androidx.compose.ui.Modifier40import androidx.compose.ui.draw.clip41import androidx.compose.ui.graphics.Color42import androidx.compose.ui.graphics.SolidColor43import androidx.compose.ui.text.TextStyle44import androidx.compose.ui.text.font.FontStyle45import androidx.compose.ui.text.input.PasswordVisualTransformation46import androidx.compose.ui.unit.dp47import androidx.compose.ui.unit.sp48import androidx.hilt.navigation.compose.hiltViewModel49import androidx.lifecycle.ViewModel50import androidx.lifecycle.viewModelScope51import com.peachpasswords.ui.components.PeachEmptyOrEndState52import com.peachpasswords.ui.components.PeachField as DesignPeachField53import com.peachpasswords.ui.components.PeachSectionHeader54import com.peachpasswords.ui.lock.canSubmitCreateVault55import com.peachpasswords.ui.security.scorePassword56import com.peachpasswords.ui.security.strengthColor57import com.peachpasswords.ui.security.strengthLabel58import com.peachpasswords.ui.theme.LocalPeachAccent59import com.peachpasswords.ui.theme.PeachSpacing60import com.peachpasswords.ui.theme.peachColors61import com.peachpasswords.ui.theme.PeachType62import com.peachpasswords.ui.theme.ShellBackground63import com.peachpasswords.vault.CreateVaultResult64import com.peachpasswords.vault.VaultEntry65import com.peachpasswords.vault.VaultRepository66import com.peachpasswords.vault.applyImportedPresentationSettings67import dagger.hilt.android.lifecycle.HiltViewModel68import dagger.hilt.android.qualifiers.ApplicationContext69import kotlinx.coroutines.Dispatchers70import kotlinx.coroutines.flow.MutableStateFlow71import kotlinx.coroutines.flow.StateFlow72import kotlinx.coroutines.flow.asStateFlow73import kotlinx.coroutines.launch74import kotlinx.coroutines.withContext75import javax.inject.Inject76import com.peachpasswords.license.LicenseRepository77787980818283enum class ImportMode { MERGE, REPLACE }8485868788899091data class ImportPreview(92 val sourceLabel: String,93 val entries: List<ParsedEntry>,94 val fullEntries: List<VaultEntry>? = null,95 val settings: com.peachpasswords.vault.VaultSettings? = null,96 val licenseKey: String? = null,97)9899sealed interface ImportUiState {100 data object Idle : ImportUiState101 102 data class Working(val step: String? = null) : ImportUiState103 data class PeachScriptFile(val text: String) : ImportUiState104 data class Preview(val preview: ImportPreview) : ImportUiState105 data class Done(val count: Int, val createdVault: Boolean = false) : ImportUiState106 data class Failed(val message: String) : ImportUiState107}108109object ImportErrors {110 const val UNRECOGNISED = "This doesn't look like PeachScript. Make sure you copied the entire backup string."111 const val WRONG_PASSWORD = "Wrong backup password. This is the password you chose when you created the backup."112 const val CORRUPTED = "This Peach backup file looks corrupted or incomplete."113 const val NO_ENTRIES = "No importable password entries were found in that file."114 const val UNREADABLE_FILE = "Couldn't read that file."115 const val UNRECOGNISED_FILE = "Unrecognised file format. Use CSV, Bitwarden JSON, Proton Pass JSON/CSV, or a Peach encrypted backup."116 const val NO_MASTER_PASSWORD = "Choose a master password for the new vault first."117 const val TOO_LARGE = "That file is too big to import. Peach accepts up to 50 MB and 10,000 entries."118}119120internal fun shouldRouteFileToPeachScript(path: String?, content: String): Boolean {121 val path = path?.lowercase().orEmpty()122 return path.endsWith(".peach") || PeachScriptImport.looksLikePeachScript(content.trim())123}124125@HiltViewModel126class ImportViewModel @Inject constructor(127 private val vault: VaultRepository,128 private val license: LicenseRepository,129 @ApplicationContext private val appContext: Context,130) : ViewModel() {131132 private val _state = MutableStateFlow<ImportUiState>(ImportUiState.Idle)133 val state: StateFlow<ImportUiState> = _state.asStateFlow()134135 136 private val _detectedProvider = MutableStateFlow<String?>(null)137 val detectedProvider: StateFlow<String?> = _detectedProvider.asStateFlow()138139 140141142143144145 val vaultUnlocked: StateFlow<Boolean> = vault.isUnlocked146147 fun reset() {148 _state.value = ImportUiState.Idle149 _detectedProvider.value = null150 }151152 153 fun expectTransientBackground() = vault.expectTransientBackground()154155 fun back(onBack: () -> Unit) {156 157 when (_state.value) {158 is ImportUiState.Preview,159 is ImportUiState.Failed,160 is ImportUiState.Done,161 is ImportUiState.PeachScriptFile,162 -> reset()163 else -> onBack()164 }165 }166167 168169 fun importPeachScript(text: String, backupPassword: String) {170 val trimmed = text.trim()171 if (trimmed.isEmpty() || !PeachScriptImport.looksLikePeachScript(trimmed)) {172 _state.value = ImportUiState.Failed(ImportErrors.UNRECOGNISED)173 return174 }175 _state.value = ImportUiState.Working()176 viewModelScope.launch {177 val result = withContext(Dispatchers.Default) {178 PeachScriptImport.decryptAndParse(trimmed, backupPassword)179 }180 _state.value = when (result) {181 is PeachScriptImport.DecryptResult.Success ->182 if (result.entries.isEmpty()) {183 ImportUiState.Failed(ImportErrors.NO_ENTRIES)184 } else {185 val parsed = result.entries.map { e ->186 ParsedEntry(187 title = e.title,188 username = e.username ?: e.email,189 password = e.password,190 url = e.url,191 notes = e.notes,192 totp = e.totp,193 )194 }195 ImportUiState.Preview(196 ImportPreview(197 sourceLabel = "PeachScript",198 entries = parsed,199 fullEntries = result.entries,200 settings = result.settings,201 licenseKey = result.licenseKey,202 ),203 )204 }205 PeachScriptImport.DecryptResult.WrongPassword ->206 ImportUiState.Failed(ImportErrors.WRONG_PASSWORD)207 PeachScriptImport.DecryptResult.Corrupted ->208 ImportUiState.Failed(ImportErrors.CORRUPTED)209 }210 }211 }212213 214 fun peachScriptNeedsPassword(text: String): Boolean {215 val t = text.trim()216 return PeachScriptImport.looksLikePeachScript(t) && PeachScriptImport.isEncrypted(t)217 }218219 220221 fun importFile(uri: Uri) {222 _state.value = ImportUiState.Working()223 _detectedProvider.value = null224 viewModelScope.launch {225 226 227 228 val read: Result<String?> = withContext(Dispatchers.IO) {229 runCatching {230 val stream = appContext.contentResolver.openInputStream(uri)231 ?: throw java.io.IOException("no_stream")232 stream.use { CsvImport.readBoundedUtf8(it) }233 }234 }235 if (read.isSuccess && read.getOrNull() == null) {236 _state.value = ImportUiState.Failed(ImportErrors.TOO_LARGE)237 return@launch238 }239 val content = read.getOrNull()240 if (content.isNullOrBlank()) {241 _state.value = ImportUiState.Failed(ImportErrors.UNREADABLE_FILE)242 return@launch243 }244 if (shouldRouteFileToPeachScript(uri.lastPathSegment, content)) {245 _state.value = ImportUiState.PeachScriptFile(content)246 return@launch247 }248 val result = withContext(Dispatchers.Default) {249 runCatching { CsvImport.detectAndParse(content) }250 .getOrElse { ImportParseResult.Failure(ImportParseResult.Reason.TOO_LARGE) }251 }252 when (result) {253 is ImportParseResult.Success -> {254 _detectedProvider.value = result.provider255 _state.value = ImportUiState.Preview(ImportPreview(result.provider, result.entries))256 }257 is ImportParseResult.Failure -> {258 _state.value = ImportUiState.Failed(259 when (result.reason) {260 ImportParseResult.Reason.UNRECOGNISED -> ImportErrors.UNRECOGNISED_FILE261 ImportParseResult.Reason.CORRUPTED -> ImportErrors.CORRUPTED262 ImportParseResult.Reason.NO_ENTRIES -> ImportErrors.NO_ENTRIES263 ImportParseResult.Reason.TOO_LARGE -> ImportErrors.TOO_LARGE264 },265 )266 }267 }268 }269 }270271 272273 274275276277278279280281282283284285 fun confirmImport(preview: ImportPreview, mode: ImportMode, newVaultPassword: String? = null) {286 _state.value = ImportUiState.Working()287 viewModelScope.launch {288 289 290 var createdForImport = false291 try {292 val createdVault: Boolean293 if (!vault.isUnlocked.value) {294 if (newVaultPassword.isNullOrBlank()) {295 _state.value = ImportUiState.Failed(ImportErrors.NO_MASTER_PASSWORD)296 return@launch297 }298 _state.value = ImportUiState.Working("creating your vault…")299 300 301 val created = withContext(Dispatchers.Default) { vault.createVault(newVaultPassword) }302 when (created) {303 is CreateVaultResult.Success -> {304 createdVault = true305 createdForImport = true306 }307 is CreateVaultResult.Error -> {308 _state.value = ImportUiState.Failed(created.reason)309 return@launch310 }311 }312 } else {313 createdVault = false314 }315 val now = System.currentTimeMillis()316 val fulls = preview.fullEntries317 val drafts = if (fulls != null) {318 319 320 321 322 323 324 if (mode == ImportMode.REPLACE) fulls else fulls.map { it.copy(id = "") }325 } else {326 preview.entries.flatMap { p ->327 328 329 val passkeyOnly = p.passkeys.isNotEmpty() &&330 p.password == null && p.username == null &&331 p.totp == null && p.notes == null332 val login = if (passkeyOnly) null else VaultEntry(333 id = "", type = "login", title = p.title, username = p.username,334 password = p.password, url = p.url, notes = p.notes, totp = p.totp,335 createdAt = now, modifiedAt = now,336 )337 val passkeyEntries = p.passkeys.map { pk ->338 VaultEntry(339 id = "", type = "login", title = p.title,340 username = p.username, url = "https://${pk.rpId}",341 rpId = pk.rpId, rpName = pk.rpName,342 credentialId = pk.credentialId,343 privateKeyJwk = pk.privateKeyJwk,344 algorithm = pk.algorithm,345 userHandle = pk.userHandle,346 userName = pk.userName,347 userDisplayName = pk.userDisplayName,348 signCount = pk.signCount,349 createdAt = now, modifiedAt = now,350 )351 }352 listOfNotNull(login) + passkeyEntries353 }354 }355 _state.value = ImportUiState.Working("saving ${drafts.size} entries…")356 357 358 359 360 361 362 val imported = withContext(Dispatchers.Default) {363 if (mode == ImportMode.REPLACE) vault.replaceEntries(drafts) else vault.addEntries(drafts)364 }365 withContext(Dispatchers.Default) {366 preview.settings?.let { importedSettings ->367 vault.updateSettings(368 applyImportedPresentationSettings(vault.settings.value, importedSettings),369 )370 }371 preview.licenseKey?.let { license.storeLicense(it) }372 }373 _state.value = ImportUiState.Done(imported, createdVault = createdVault)374 } catch (e: Exception) {375 376 377 378 379 if (createdForImport) runCatching { vault.destroyVault() }380 _state.value = ImportUiState.Failed(ImportErrors.CORRUPTED)381 }382 }383 }384}385386387388389390private enum class ImportRoute { CHOOSE, PEACHSCRIPT, FILE }391392@Composable393fun ImportScreen(394 onBack: () -> Unit,395 396 397 onCodex: () -> Unit = {},398 399 400 401 402 onVaultCreated: () -> Unit = onBack,403 vm: ImportViewModel = hiltViewModel(),404) {405 val state by vm.state.collectAsState()406 val vaultUnlocked by vm.vaultUnlocked.collectAsState()407 var route by remember { mutableStateOf(ImportRoute.CHOOSE) }408 409 410 var resolveDone by remember(state is ImportUiState.Done) { mutableStateOf(false) }411412 ShellBackground {413 Column(414 modifier = Modifier415 .fillMaxSize()416 .systemBarsPadding()417 .padding(horizontal = PeachSpacing.lg, vertical = PeachSpacing.md),418 ) {419 PeachSectionHeader(420 section = "import",421 onHome = {422 when {423 state !is ImportUiState.Idle -> vm.back(onBack)424 route != ImportRoute.CHOOSE -> route = ImportRoute.CHOOSE425 else -> onBack()426 }427 },428 )429430 Spacer(Modifier.size(PeachSpacing.lg))431432 if (state == ImportUiState.Idle && route == ImportRoute.CHOOSE) {433 434 435 436 ChooseView(437 onPeachScript = { route = ImportRoute.PEACHSCRIPT },438 onFile = { route = ImportRoute.FILE },439 onCodex = onCodex,440 modifier = Modifier.weight(1f).fillMaxWidth(),441 composeRemainingSpace = true,442 )443 } else {444 Column(Modifier.fillMaxSize().verticalScroll(rememberScrollState())) {445 when (val s = state) {446 is ImportUiState.Preview -> PreviewView(447 preview = s.preview,448 vaultUnlocked = vaultUnlocked,449 onConfirm = { mode, newPassword -> vm.confirmImport(s.preview, mode, newPassword) },450 onCancel = { vm.reset() },451 )452 is ImportUiState.Done -> {453 454 455 456 if (route == ImportRoute.PEACHSCRIPT && !resolveDone) {457 var bloomed by remember { mutableStateOf(false) }458 RuneResolve(459 phase = if (bloomed) RunePhase.BLOOM else RunePhase.SNAP,460 onFinished = { if (bloomed) resolveDone = true else bloomed = true },461 )462 } else {463 DoneView(464 count = s.count,465 onDone = if (s.createdVault) onVaultCreated else onBack,466 )467 }468 }469 is ImportUiState.Failed -> {470 ErrorBanner(s.message)471 Spacer(Modifier.size(16.dp))472 when (route) {473 ImportRoute.PEACHSCRIPT -> PeachScriptView(vm)474 ImportRoute.FILE -> FileView(vm)475 ImportRoute.CHOOSE -> ChooseView(476 onPeachScript = { route = ImportRoute.PEACHSCRIPT },477 onFile = { route = ImportRoute.FILE },478 onCodex = onCodex,479 )480 }481 }482 is ImportUiState.Working -> {483 if (route == ImportRoute.PEACHSCRIPT) RuneResolve(RunePhase.CHAOS)484 WorkingView(s.step)485 }486 ImportUiState.Idle -> when (route) {487 ImportRoute.CHOOSE -> Unit 488 ImportRoute.PEACHSCRIPT -> PeachScriptView(vm)489 ImportRoute.FILE -> FileView(vm)490 }491 is ImportUiState.PeachScriptFile -> {492 LaunchedEffect(s.text) { route = ImportRoute.PEACHSCRIPT }493 PeachScriptView(vm, initialPaste = s.text)494 }495 }496 }497 }498 }499 }500}501502503504@Composable505private fun ChooseView(506 onPeachScript: () -> Unit,507 onFile: () -> Unit,508 onCodex: () -> Unit,509 modifier: Modifier = Modifier,510 511 512 513 514 composeRemainingSpace: Boolean = false,515) {516 Column(modifier) {517 OptionCard(518 title = "From PeachScript",519 subtitle = "Paste an encrypted Peach export",520 onClick = onPeachScript,521 )522 Spacer(Modifier.size(12.dp))523 OptionCard(524 title = "From file",525 subtitle = "Bitwarden, Proton Pass, 1Password, Chrome, Safari, Edge CSV",526 onClick = onFile,527 )528 Spacer(Modifier.size(12.dp))529 OptionCard(530 title = "From Peach Codex",531 subtitle = "Scan or open a Codex PDF you printed",532 onClick = onCodex,533 )534 if (composeRemainingSpace) {535 Box(Modifier.weight(1f).fillMaxWidth(), contentAlignment = Alignment.Center) {536 PeachEmptyOrEndState(537 message = "Choose an import source.",538 detail = "Peach will preview everything before it changes your vault.",539 )540 }541 } else {542 PeachEmptyOrEndState(543 message = "Choose an import source.",544 detail = "Peach will preview everything before it changes your vault.",545 )546 }547 }548}549550@Composable551private fun OptionCard(title: String, subtitle: String, onClick: () -> Unit) {552 Column(553 modifier = Modifier554 .fillMaxWidth()555 .clip(RoundedCornerShape(18.dp))556 .background(peachColors.SurfaceElevated)557 .border(1.dp, peachColors.T3, RoundedCornerShape(18.dp))558 .clickable(onClick = onClick)559 .padding(20.dp),560 ) {561 Text(562 title,563 color = peachColors.T0,564 style = TextStyle(fontFamily = PeachType.Display, fontStyle = FontStyle.Italic, fontSize = 22.sp),565 )566 Spacer(Modifier.size(6.dp))567 Text(subtitle, color = peachColors.T1, style = PeachType.body)568 }569}570571572573@Composable574private fun PeachScriptView(vm: ImportViewModel, initialPaste: String = "") {575 576 577 578 579 580 val fromFile = initialPaste.isNotEmpty()581 var paste by remember(initialPaste) { mutableStateOf(if (fromFile) "" else initialPaste) }582 var backupPassword by remember { mutableStateOf("") }583 val source = if (fromFile) initialPaste else paste584 val needsPassword = remember(source) { vm.peachScriptNeedsPassword(source) }585586 GlassCard {587 if (fromFile) {588 FieldLabel("Backup loaded from file")589 Spacer(Modifier.size(PeachSpacing.sm))590 Text(591 "%,d characters of PeachScript ready to import.".format(initialPaste.length),592 color = peachColors.T1,593 style = PeachType.body,594 )595 } else {596 FieldLabel("Paste your PeachScript export")597 Spacer(Modifier.size(PeachSpacing.sm))598 PeachMultilineField(599 value = paste,600 onValueChange = { paste = it },601 minLines = 6,602 )603 }604 if (needsPassword) {605 Spacer(Modifier.size(PeachSpacing.lg))606 PeachUnderlineField(607 label = "Backup password",608 value = backupPassword,609 onValueChange = { backupPassword = it },610 isPassword = true,611 )612 }613 }614 Spacer(Modifier.size(PeachSpacing.lg))615 AccentButton(616 label = "Import",617 enabled = source.isNotBlank(),618 onClick = { vm.importPeachScript(source, backupPassword) },619 )620}621622623624@Composable625private fun FileView(vm: ImportViewModel) {626 val detected by vm.detectedProvider.collectAsState()627 val picker = rememberLauncherForActivityResult(628 ActivityResultContracts.GetContent(),629 ) { uri -> if (uri != null) vm.importFile(uri) }630631 GlassCard {632 Text(633 "Import from a CSV or JSON export",634 color = peachColors.T1,635 style = PeachType.body,636 )637 Spacer(Modifier.size(PeachSpacing.sm))638 Text(639 "Supports Bitwarden, Proton Pass, 1Password, Chrome/Edge, and Safari exports.",640 color = peachColors.T2,641 style = PeachType.hint,642 )643 Spacer(Modifier.size(PeachSpacing.lg))644 GhostButton("Choose file", LocalPeachAccent.current) {645 646 647 648 vm.expectTransientBackground()649 picker.launch("*/*")650 }651 if (detected != null) {652 Spacer(Modifier.size(PeachSpacing.md))653 Text(654 "Detected: $detected export",655 color = LocalPeachAccent.current,656 style = PeachType.labelMono,657 )658 }659 }660}661662663664@Composable665private fun PreviewView(666 preview: ImportPreview,667 vaultUnlocked: Boolean,668 onConfirm: (ImportMode, String?) -> Unit,669 onCancel: () -> Unit,670) {671 var mode by remember { mutableStateOf(ImportMode.MERGE) }672 var newPassword by remember { mutableStateOf("") }673 var newConfirm by remember { mutableStateOf("") }674 val createScore = remember(newPassword) { scorePassword(newPassword) }675676 GlassCard {677 Text(678 "Detected: ${preview.sourceLabel} export",679 color = LocalPeachAccent.current,680 style = PeachType.labelMono,681 )682 Spacer(Modifier.size(PeachSpacing.sm))683 Text(684 "Found ${preview.entries.size} ${if (preview.entries.size == 1) "entry" else "entries"}",685 color = peachColors.T0,686 style = TextStyle(fontFamily = PeachType.Display, fontStyle = FontStyle.Italic, fontSize = 22.sp),687 )688 Spacer(Modifier.size(PeachSpacing.md))689 690 Column(691 modifier = Modifier692 .fillMaxWidth()693 .heightIn(max = 150.dp)694 .verticalScroll(rememberScrollState()),695 ) {696 preview.entries.take(5).forEach { e ->697 Text(698 e.title,699 color = peachColors.T1,700 style = PeachType.entryNameList,701 modifier = Modifier.padding(vertical = 4.dp),702 )703 }704 if (preview.entries.size > 5) {705 Text(706 "+${preview.entries.size - 5} more",707 color = peachColors.T2,708 style = PeachType.tinyMono,709 modifier = Modifier.padding(top = 4.dp),710 )711 }712 }713 Divider()714 if (vaultUnlocked) {715 RadioRow("Merge with existing vault", selected = mode == ImportMode.MERGE) { mode = ImportMode.MERGE }716 Spacer(Modifier.size(PeachSpacing.sm))717 RadioRow("Replace vault", selected = mode == ImportMode.REPLACE) { mode = ImportMode.REPLACE }718 } else {719 FieldLabel("No vault yet — create one to restore into")720 Spacer(Modifier.size(PeachSpacing.sm))721 PeachUnderlineField(722 label = "Master password",723 value = newPassword,724 onValueChange = { newPassword = it },725 isPassword = true,726 )727 Spacer(Modifier.size(PeachSpacing.md))728 PeachUnderlineField(729 label = "Confirm password",730 value = newConfirm,731 onValueChange = { newConfirm = it },732 isPassword = true,733 )734 if (newPassword.isNotEmpty()) {735 Spacer(Modifier.size(PeachSpacing.sm))736 Text(737 "strength: ${strengthLabel(createScore)}",738 color = strengthColor(createScore),739 style = PeachType.labelMono,740 )741 }742 }743 }744 Spacer(Modifier.size(PeachSpacing.lg))745 val confirmEnabled = vaultUnlocked ||746 canSubmitCreateVault(newPassword, newConfirm, createScore)747 AccentButton(748 label = "Confirm Import",749 enabled = confirmEnabled,750 onClick = { onConfirm(mode, if (vaultUnlocked) null else newPassword) },751 )752 Spacer(Modifier.size(PeachSpacing.sm))753 GhostButton("Cancel", peachColors.T1, onCancel)754}755756@Composable757private fun RadioRow(label: String, selected: Boolean, onClick: () -> Unit) {758 val accent = LocalPeachAccent.current759 Row(760 verticalAlignment = Alignment.CenterVertically,761 modifier = Modifier.fillMaxWidth().clickable(onClick = onClick).padding(vertical = 4.dp),762 ) {763 Box(764 modifier = Modifier765 .size(16.dp)766 .clip(RoundedCornerShape(999.dp))767 .border(1.5.dp, if (selected) accent else peachColors.T2, RoundedCornerShape(999.dp)),768 contentAlignment = Alignment.Center,769 ) {770 if (selected) {771 Box(Modifier.size(8.dp).clip(RoundedCornerShape(999.dp)).background(accent))772 }773 }774 Spacer(Modifier.width(12.dp))775 Text(label, color = peachColors.T0, style = PeachType.body)776 }777}778779780781@Composable782private fun DoneView(count: Int, onDone: () -> Unit) {783 GlassCard {784 Text(785 "imported.",786 color = peachColors.T0,787 style = TextStyle(fontFamily = PeachType.Display, fontStyle = FontStyle.Italic, fontSize = 24.sp),788 )789 Spacer(Modifier.size(PeachSpacing.sm))790 Text(791 "$count ${if (count == 1) "entry" else "entries"} imported.",792 color = peachColors.T1,793 style = PeachType.body,794 )795 }796 Spacer(Modifier.size(PeachSpacing.lg))797 AccentButton(label = "Done", onClick = onDone)798}799800@Composable801private fun WorkingView(step: String? = null) {802 Column(803 Modifier.fillMaxWidth().padding(vertical = 24.dp),804 horizontalAlignment = Alignment.CenterHorizontally,805 ) {806 Text(807 step ?: "working…",808 color = peachColors.T1,809 style = TextStyle(fontFamily = PeachType.Display, fontStyle = FontStyle.Italic, fontSize = 18.sp),810 )811 Spacer(Modifier.size(PeachSpacing.md))812 813 814 LinearProgressIndicator(815 modifier = Modifier.fillMaxWidth(),816 color = LocalPeachAccent.current,817 trackColor = peachColors.T3,818 )819 Spacer(Modifier.size(PeachSpacing.sm))820 Text(821 "keep Peach open — this can take a moment for a large vault.",822 color = peachColors.T2,823 style = PeachType.hint,824 )825 }826}827828@Composable829private fun ErrorBanner(message: String) {830 Box(831 modifier = Modifier832 .fillMaxWidth()833 .clip(RoundedCornerShape(12.dp))834 .background(peachColors.BreachBg)835 .border(1.dp, peachColors.BreachBorder, RoundedCornerShape(12.dp))836 .padding(14.dp),837 ) {838 Text(message, color = peachColors.Breach, style = PeachType.bodyMono)839 }840}841842843844@Composable845private fun FieldLabel(text: String) {846 Text(847 text.uppercase(),848 color = peachColors.T2,849 style = PeachType.labelMono,850 )851}852853@Composable854private fun PeachMultilineField(855 value: String,856 onValueChange: (String) -> Unit,857 minLines: Int,858) {859 val accent = LocalPeachAccent.current860 Box(861 modifier = Modifier862 .fillMaxWidth()863 .clip(RoundedCornerShape(12.dp))864 .background(peachColors.S2)865 .border(1.dp, peachColors.T3, RoundedCornerShape(12.dp))866 .padding(14.dp),867 ) {868 BasicTextField(869 value = value,870 onValueChange = onValueChange,871 872 873 874 textStyle = PeachType.bodyMono.copy(color = peachColors.T0, fontFamily = PeachType.Script),875 cursorBrush = SolidColor(accent),876 minLines = minLines,877 modifier = Modifier.fillMaxWidth(),878 )879 if (value.isEmpty()) {880 Text(881 "{ \"peachscript\": 1, … }",882 color = peachColors.T2,883 style = PeachType.bodyMono,884 )885 }886 }887}888889@Composable890private fun PeachUnderlineField(891 label: String,892 value: String,893 onValueChange: (String) -> Unit,894 isPassword: Boolean = false,895) {896 DesignPeachField(897 label = label,898 value = value,899 onValueChange = onValueChange,900 visualTransformation = if (isPassword) PasswordVisualTransformation() else androidx.compose.ui.text.input.VisualTransformation.None,901 )902}903904@Composable905private fun GlassCard(content: @Composable () -> Unit) {906 Column(907 modifier = Modifier908 .fillMaxWidth()909 .clip(RoundedCornerShape(18.dp))910 .background(peachColors.SurfaceElevated)911 .border(1.dp, peachColors.T3, RoundedCornerShape(18.dp))912 .padding(20.dp),913 content = { content() },914 )915}916917@Composable918private fun Divider() {919 Box(Modifier.fillMaxWidth().padding(vertical = 14.dp).size(1.dp).background(peachColors.T3))920}921922@Composable923private fun GhostButton(label: String, color: Color, onClick: () -> Unit) {924 Box(925 modifier = Modifier926 .fillMaxWidth()927 .clip(RoundedCornerShape(12.dp))928 .border(1.dp, color.copy(alpha = 0.4f), RoundedCornerShape(12.dp))929 .clickable(onClick = onClick)930 .padding(vertical = 12.dp),931 contentAlignment = Alignment.Center,932 ) {933 Text(label, color = color, style = PeachType.bodyMono)934 }935}936937@Composable938private fun AccentButton(label: String, onClick: () -> Unit, enabled: Boolean = true) {939 val accent = LocalPeachAccent.current940 Box(941 modifier = Modifier942 .fillMaxWidth()943 .clip(RoundedCornerShape(12.dp))944 .background(if (enabled) accent else accent.copy(alpha = 0.3f))945 .clickable(enabled = enabled, onClick = onClick)946 .padding(vertical = 14.dp),947 contentAlignment = Alignment.Center,948 ) {949 Text(label, color = Color(0xFF0C0A06), style = PeachType.bodyMono)950 }951}