package com.peachpasswords.ui.vault import android.content.Context import android.net.Uri import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.foundation.background import androidx.compose.foundation.border import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.systemBarsPadding import androidx.compose.foundation.layout.width import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.text.BasicTextField import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.rounded.ArrowBack import androidx.compose.material3.Icon import androidx.compose.material3.LinearProgressIndicator import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.font.FontStyle import androidx.compose.ui.text.input.PasswordVisualTransformation import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.peachpasswords.ui.components.PeachEmptyOrEndState import com.peachpasswords.ui.components.PeachField as DesignPeachField import com.peachpasswords.ui.components.PeachSectionHeader import com.peachpasswords.ui.lock.canSubmitCreateVault import com.peachpasswords.ui.security.scorePassword import com.peachpasswords.ui.security.strengthColor import com.peachpasswords.ui.security.strengthLabel import com.peachpasswords.ui.theme.LocalPeachAccent import com.peachpasswords.ui.theme.PeachSpacing import com.peachpasswords.ui.theme.peachColors import com.peachpasswords.ui.theme.PeachType import com.peachpasswords.ui.theme.ShellBackground import com.peachpasswords.vault.CreateVaultResult import com.peachpasswords.vault.VaultEntry import com.peachpasswords.vault.VaultRepository import com.peachpasswords.vault.applyImportedPresentationSettings import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import javax.inject.Inject import com.peachpasswords.license.LicenseRepository // ───────────────────────────────────────────────────────────────────────────── // ViewModel // ───────────────────────────────────────────────────────────────────────────── /** How parsed entries land in the vault on confirm. */ enum class ImportMode { MERGE, REPLACE } /** * A staged, parsed import ready for preview + confirm. [entries] is the * ParsedEntry projection used by the preview UI (shared with CSV). [fullEntries] * is set only for PeachScript, carrying the complete VaultEntry field set so the * import preserves tags, card fields, canary, breach, timestamps, etc. */ data class ImportPreview( val sourceLabel: String, val entries: List, val fullEntries: List? = null, val settings: com.peachpasswords.vault.VaultSettings? = null, val licenseKey: String? = null, ) sealed interface ImportUiState { data object Idle : ImportUiState /** [step] is user-facing progress copy; null while there's nothing to say yet. */ data class Working(val step: String? = null) : ImportUiState data class PeachScriptFile(val text: String) : ImportUiState data class Preview(val preview: ImportPreview) : ImportUiState data class Done(val count: Int, val createdVault: Boolean = false) : ImportUiState data class Failed(val message: String) : ImportUiState } object ImportErrors { const val UNRECOGNISED = "This doesn't look like PeachScript. Make sure you copied the entire backup string." const val WRONG_PASSWORD = "Wrong backup password. This is the password you chose when you created the backup." const val CORRUPTED = "This Peach backup file looks corrupted or incomplete." const val NO_ENTRIES = "No importable password entries were found in that file." const val UNREADABLE_FILE = "Couldn't read that file." const val UNRECOGNISED_FILE = "Unrecognised file format. Use CSV, Bitwarden JSON, Proton Pass JSON/CSV, or a Peach encrypted backup." const val NO_MASTER_PASSWORD = "Choose a master password for the new vault first." const val TOO_LARGE = "That file is too big to import. Peach accepts up to 50 MB and 10,000 entries." } internal fun shouldRouteFileToPeachScript(path: String?, content: String): Boolean { val path = path?.lowercase().orEmpty() return path.endsWith(".peach") || PeachScriptImport.looksLikePeachScript(content.trim()) } @HiltViewModel class ImportViewModel @Inject constructor( private val vault: VaultRepository, private val license: LicenseRepository, @ApplicationContext private val appContext: Context, ) : ViewModel() { private val _state = MutableStateFlow(ImportUiState.Idle) val state: StateFlow = _state.asStateFlow() /** Detected provider label for the file sub-view ("Detected: X export"). */ private val _detectedProvider = MutableStateFlow(null) val detectedProvider: StateFlow = _detectedProvider.asStateFlow() /** * False when this screen was reached with no vault to import into yet — fresh * install ("restore instead") or "forgot your password" recovery. The preview * step then collects a new master password and [confirmImport] creates the * vault before adding entries, instead of every addEntry silently failing. */ val vaultUnlocked: StateFlow = vault.isUnlocked fun reset() { _state.value = ImportUiState.Idle _detectedProvider.value = null } /** See VaultRepository.expectTransientBackground — call before launching the SAF picker. */ fun expectTransientBackground() = vault.expectTransientBackground() fun back(onBack: () -> Unit) { // Preview/error back returns to the input view; otherwise leave the screen. when (_state.value) { is ImportUiState.Preview, is ImportUiState.Failed, is ImportUiState.Done, is ImportUiState.PeachScriptFile, -> reset() else -> onBack() } } // ---- PeachScript paste ------------------------------------------------ fun importPeachScript(text: String, backupPassword: String) { val trimmed = text.trim() if (trimmed.isEmpty() || !PeachScriptImport.looksLikePeachScript(trimmed)) { _state.value = ImportUiState.Failed(ImportErrors.UNRECOGNISED) return } _state.value = ImportUiState.Working() viewModelScope.launch { val result = withContext(Dispatchers.Default) { PeachScriptImport.decryptAndParse(trimmed, backupPassword) } _state.value = when (result) { is PeachScriptImport.DecryptResult.Success -> if (result.entries.isEmpty()) { ImportUiState.Failed(ImportErrors.NO_ENTRIES) } else { val parsed = result.entries.map { e -> ParsedEntry( title = e.title, username = e.username ?: e.email, password = e.password, url = e.url, notes = e.notes, totp = e.totp, ) } ImportUiState.Preview( ImportPreview( sourceLabel = "PeachScript", entries = parsed, fullEntries = result.entries, settings = result.settings, licenseKey = result.licenseKey, ), ) } PeachScriptImport.DecryptResult.WrongPassword -> ImportUiState.Failed(ImportErrors.WRONG_PASSWORD) PeachScriptImport.DecryptResult.Corrupted -> ImportUiState.Failed(ImportErrors.CORRUPTED) } } } /** True when the pasted text is a PeachScript export that needs a password. */ fun peachScriptNeedsPassword(text: String): Boolean { val t = text.trim() return PeachScriptImport.looksLikePeachScript(t) && PeachScriptImport.isEncrypted(t) } // ---- File import ------------------------------------------------------ fun importFile(uri: Uri) { _state.value = ImportUiState.Working() _detectedProvider.value = null viewModelScope.launch { // M-01: a content URI is an arbitrary provider stream — read it // bounded so an oversize (or endless) file is refused instead of // filling the heap. null distinguishes "over the cap" from "unreadable". val read: Result = withContext(Dispatchers.IO) { runCatching { val stream = appContext.contentResolver.openInputStream(uri) ?: throw java.io.IOException("no_stream") stream.use { CsvImport.readBoundedUtf8(it) } } } if (read.isSuccess && read.getOrNull() == null) { _state.value = ImportUiState.Failed(ImportErrors.TOO_LARGE) return@launch } val content = read.getOrNull() if (content.isNullOrBlank()) { _state.value = ImportUiState.Failed(ImportErrors.UNREADABLE_FILE) return@launch } if (shouldRouteFileToPeachScript(uri.lastPathSegment, content)) { _state.value = ImportUiState.PeachScriptFile(content) return@launch } val result = withContext(Dispatchers.Default) { runCatching { CsvImport.detectAndParse(content) } .getOrElse { ImportParseResult.Failure(ImportParseResult.Reason.TOO_LARGE) } } when (result) { is ImportParseResult.Success -> { _detectedProvider.value = result.provider _state.value = ImportUiState.Preview(ImportPreview(result.provider, result.entries)) } is ImportParseResult.Failure -> { _state.value = ImportUiState.Failed( when (result.reason) { ImportParseResult.Reason.UNRECOGNISED -> ImportErrors.UNRECOGNISED_FILE ImportParseResult.Reason.CORRUPTED -> ImportErrors.CORRUPTED ImportParseResult.Reason.NO_ENTRIES -> ImportErrors.NO_ENTRIES ImportParseResult.Reason.TOO_LARGE -> ImportErrors.TOO_LARGE }, ) } } } } // ---- Confirm ---------------------------------------------------------- /** * Persist the previewed entries in one write: MERGE appends via * [VaultRepository.addEntries], REPLACE swaps the active set via * [VaultRepository.replaceEntries] so a failure cannot leave the old * entries tombstoned with nothing imported. * * [newVaultPassword] is required when [vaultUnlocked] is false: there's no * vault yet (fresh install "restore instead", or "forgot password" recovery) * so one is created from it before any entry can be added. Without this, * every addEntry threw requireUnlocked() and got swallowed by runCatching, * silently reporting "0 entries imported" as if it had succeeded. */ fun confirmImport(preview: ImportPreview, mode: ImportMode, newVaultPassword: String? = null) { _state.value = ImportUiState.Working() viewModelScope.launch { // Declared outside the try so the failure path knows whether this // vault only exists to hold the import (M-01 rollback below). var createdForImport = false try { val createdVault: Boolean if (!vault.isUnlocked.value) { if (newVaultPassword.isNullOrBlank()) { _state.value = ImportUiState.Failed(ImportErrors.NO_MASTER_PASSWORD) return@launch } _state.value = ImportUiState.Working("creating your vault…") // Key derivation is deliberately expensive; off the main // thread or it blocks the frame loop on its own. val created = withContext(Dispatchers.Default) { vault.createVault(newVaultPassword) } when (created) { is CreateVaultResult.Success -> { createdVault = true createdForImport = true } is CreateVaultResult.Error -> { _state.value = ImportUiState.Failed(created.reason) return@launch } } } else { createdVault = false } val now = System.currentTimeMillis() val fulls = preview.fullEntries val drafts = if (fulls != null) { // PeachScript: the complete entries (all fields, all types). // M-03: REPLACE is a restore of *these* entries, so keep their // ids — re-iding turned a restore into delete-plus-new // identities that paired devices then re-synced as churn. // MERGE folds into an existing set, so re-id there: it is the // explicit collision resolution (never overwrite a live entry). if (mode == ImportMode.REPLACE) fulls else fulls.map { it.copy(id = "") } } else { preview.entries.flatMap { p -> // A passkey-only entry (CXF) carries no login fields; spawning // an empty login for it would leave junk the browser never creates. val passkeyOnly = p.passkeys.isNotEmpty() && p.password == null && p.username == null && p.totp == null && p.notes == null val login = if (passkeyOnly) null else VaultEntry( id = "", type = "login", title = p.title, username = p.username, password = p.password, url = p.url, notes = p.notes, totp = p.totp, createdAt = now, modifiedAt = now, ) val passkeyEntries = p.passkeys.map { pk -> VaultEntry( id = "", type = "login", title = p.title, username = p.username, url = "https://${pk.rpId}", rpId = pk.rpId, rpName = pk.rpName, credentialId = pk.credentialId, privateKeyJwk = pk.privateKeyJwk, algorithm = pk.algorithm, userHandle = pk.userHandle, userName = pk.userName, userDisplayName = pk.userDisplayName, signCount = pk.signCount, createdAt = now, modifiedAt = now, ) } listOfNotNull(login) + passkeyEntries } } _state.value = ImportUiState.Working("saving ${drafts.size} entries…") // One batched write on a background thread. Looping addEntry // re-encrypted and rewrote the whole vault per entry, which on // a real 806-entry vault hung the app until Android offered to // kill it. M-01: REPLACE goes through replaceEntries so the // tombstoning of the old set and the insert of the new one are // a single write that rolls back together. val imported = withContext(Dispatchers.Default) { if (mode == ImportMode.REPLACE) vault.replaceEntries(drafts) else vault.addEntries(drafts) } withContext(Dispatchers.Default) { preview.settings?.let { importedSettings -> vault.updateSettings( applyImportedPresentationSettings(vault.settings.value, importedSettings), ) } preview.licenseKey?.let { license.storeLicense(it) } } _state.value = ImportUiState.Done(imported, createdVault = createdVault) } catch (e: Exception) { // M-01: a vault created purely to receive this import is not // wanted if the import failed — leaving it behind puts the user // in an empty vault they never chose a purpose for, and blocks // the "restore instead" path from being retried. if (createdForImport) runCatching { vault.destroyVault() } _state.value = ImportUiState.Failed(ImportErrors.CORRUPTED) } } } } // ───────────────────────────────────────────────────────────────────────────── // Screen // ───────────────────────────────────────────────────────────────────────────── private enum class ImportRoute { CHOOSE, PEACHSCRIPT, FILE } @Composable fun ImportScreen( onBack: () -> Unit, // Codex is the third restore format (browser RestoreTab: symbols|codex|csv). // It has its own screen, so this hands off rather than rendering inline. onCodex: () -> Unit = {}, // Only reached when confirmImport had to create a new vault first (fresh // install or "forgot password" recovery) — routes to recovery-word setup, // same as LockScreen.create, instead of popping back to a stale Lock/Import // screen that still thinks there's nothing to unlock. onVaultCreated: () -> Unit = onBack, vm: ImportViewModel = hiltViewModel(), ) { val state by vm.state.collectAsState() val vaultUnlocked by vm.vaultUnlocked.collectAsState() var route by remember { mutableStateOf(ImportRoute.CHOOSE) } // Latches once the rune resolve has played, so recomposition of a settled // Done state doesn't replay it. var resolveDone by remember(state is ImportUiState.Done) { mutableStateOf(false) } ShellBackground { Column( modifier = Modifier .fillMaxSize() .systemBarsPadding() .padding(horizontal = PeachSpacing.lg, vertical = PeachSpacing.md), ) { PeachSectionHeader( section = "import", onHome = { when { state !is ImportUiState.Idle -> vm.back(onBack) route != ImportRoute.CHOOSE -> route = ImportRoute.CHOOSE else -> onBack() } }, ) Spacer(Modifier.size(PeachSpacing.lg)) if (state == ImportUiState.Idle && route == ImportRoute.CHOOSE) { // The default landing state (two option cards + a prompt) never // needs to scroll — give it the real remaining height so the // prompt composes into it instead of trailing into dead space. ChooseView( onPeachScript = { route = ImportRoute.PEACHSCRIPT }, onFile = { route = ImportRoute.FILE }, onCodex = onCodex, modifier = Modifier.weight(1f).fillMaxWidth(), composeRemainingSpace = true, ) } else { Column(Modifier.fillMaxSize().verticalScroll(rememberScrollState())) { when (val s = state) { is ImportUiState.Preview -> PreviewView( preview = s.preview, vaultUnlocked = vaultUnlocked, onConfirm = { mode, newPassword -> vm.confirmImport(s.preview, mode, newPassword) }, onCancel = { vm.reset() }, ) is ImportUiState.Done -> { // PeachScript lands through the rune resolve (browser // RestoreSymbols): snap the drifting glyphs into a grid, // bloom the mark, then show the count. if (route == ImportRoute.PEACHSCRIPT && !resolveDone) { var bloomed by remember { mutableStateOf(false) } RuneResolve( phase = if (bloomed) RunePhase.BLOOM else RunePhase.SNAP, onFinished = { if (bloomed) resolveDone = true else bloomed = true }, ) } else { DoneView( count = s.count, onDone = if (s.createdVault) onVaultCreated else onBack, ) } } is ImportUiState.Failed -> { ErrorBanner(s.message) Spacer(Modifier.size(16.dp)) when (route) { ImportRoute.PEACHSCRIPT -> PeachScriptView(vm) ImportRoute.FILE -> FileView(vm) ImportRoute.CHOOSE -> ChooseView( onPeachScript = { route = ImportRoute.PEACHSCRIPT }, onFile = { route = ImportRoute.FILE }, onCodex = onCodex, ) } } is ImportUiState.Working -> { if (route == ImportRoute.PEACHSCRIPT) RuneResolve(RunePhase.CHAOS) WorkingView(s.step) } ImportUiState.Idle -> when (route) { ImportRoute.CHOOSE -> Unit // handled above, outside the scroll container ImportRoute.PEACHSCRIPT -> PeachScriptView(vm) ImportRoute.FILE -> FileView(vm) } is ImportUiState.PeachScriptFile -> { LaunchedEffect(s.text) { route = ImportRoute.PEACHSCRIPT } PeachScriptView(vm, initialPaste = s.text) } } } } } } } // ---- Choose view ---------------------------------------------------------- @Composable private fun ChooseView( onPeachScript: () -> Unit, onFile: () -> Unit, onCodex: () -> Unit, modifier: Modifier = Modifier, // Only true from the non-scrolling call site, where `modifier` carries a // Column-scope weight and the remaining height is real and boundable. // The scrolling call site (inside a verticalScroll Column, unbounded // height) must keep the plain flowing layout or `weight()` would crash. composeRemainingSpace: Boolean = false, ) { Column(modifier) { OptionCard( title = "From PeachScript", subtitle = "Paste an encrypted Peach export", onClick = onPeachScript, ) Spacer(Modifier.size(12.dp)) OptionCard( title = "From file", subtitle = "Bitwarden, Proton Pass, 1Password, Chrome, Safari, Edge CSV", onClick = onFile, ) Spacer(Modifier.size(12.dp)) OptionCard( title = "From Peach Codex", subtitle = "Scan or open a Codex PDF you printed", onClick = onCodex, ) if (composeRemainingSpace) { Box(Modifier.weight(1f).fillMaxWidth(), contentAlignment = Alignment.Center) { PeachEmptyOrEndState( message = "Choose an import source.", detail = "Peach will preview everything before it changes your vault.", ) } } else { PeachEmptyOrEndState( message = "Choose an import source.", detail = "Peach will preview everything before it changes your vault.", ) } } } @Composable private fun OptionCard(title: String, subtitle: String, onClick: () -> Unit) { Column( modifier = Modifier .fillMaxWidth() .clip(RoundedCornerShape(18.dp)) .background(peachColors.SurfaceElevated) .border(1.dp, peachColors.T3, RoundedCornerShape(18.dp)) .clickable(onClick = onClick) .padding(20.dp), ) { Text( title, color = peachColors.T0, style = TextStyle(fontFamily = PeachType.Display, fontStyle = FontStyle.Italic, fontSize = 22.sp), ) Spacer(Modifier.size(6.dp)) Text(subtitle, color = peachColors.T1, style = PeachType.body) } } // ---- PeachScript sub-view ------------------------------------------------- @Composable private fun PeachScriptView(vm: ImportViewModel, initialPaste: String = "") { // A backup loaded from a file never goes through the text field. A real // vault's export is hundreds of thousands of PUA characters (June's is // 262k) and pushing that into a BasicTextField bogs the UI down badly // enough that the import looks broken. The field is only for hand-pasted // text; file content is held aside and handed straight to the decoder. val fromFile = initialPaste.isNotEmpty() var paste by remember(initialPaste) { mutableStateOf(if (fromFile) "" else initialPaste) } var backupPassword by remember { mutableStateOf("") } val source = if (fromFile) initialPaste else paste val needsPassword = remember(source) { vm.peachScriptNeedsPassword(source) } GlassCard { if (fromFile) { FieldLabel("Backup loaded from file") Spacer(Modifier.size(PeachSpacing.sm)) Text( "%,d characters of PeachScript ready to import.".format(initialPaste.length), color = peachColors.T1, style = PeachType.body, ) } else { FieldLabel("Paste your PeachScript export") Spacer(Modifier.size(PeachSpacing.sm)) PeachMultilineField( value = paste, onValueChange = { paste = it }, minLines = 6, ) } if (needsPassword) { Spacer(Modifier.size(PeachSpacing.lg)) PeachUnderlineField( label = "Backup password", value = backupPassword, onValueChange = { backupPassword = it }, isPassword = true, ) } } Spacer(Modifier.size(PeachSpacing.lg)) AccentButton( label = "Import", enabled = source.isNotBlank(), onClick = { vm.importPeachScript(source, backupPassword) }, ) } // ---- File sub-view -------------------------------------------------------- @Composable private fun FileView(vm: ImportViewModel) { val detected by vm.detectedProvider.collectAsState() val picker = rememberLauncherForActivityResult( ActivityResultContracts.GetContent(), ) { uri -> if (uri != null) vm.importFile(uri) } GlassCard { Text( "Import from a CSV or JSON export", color = peachColors.T1, style = PeachType.body, ) Spacer(Modifier.size(PeachSpacing.sm)) Text( "Supports Bitwarden, Proton Pass, 1Password, Chrome/Edge, and Safari exports.", color = peachColors.T2, style = PeachType.hint, ) Spacer(Modifier.size(PeachSpacing.lg)) GhostButton("Choose file", LocalPeachAccent.current) { // GetContent takes a single MIME type; the chooser still lists CSV // and JSON. "*/*" is intentional so providers exporting odd MIME // types for .csv are not filtered out by the system picker. vm.expectTransientBackground() picker.launch("*/*") } if (detected != null) { Spacer(Modifier.size(PeachSpacing.md)) Text( "Detected: $detected export", color = LocalPeachAccent.current, style = PeachType.labelMono, ) } } } // ---- Preview -------------------------------------------------------------- @Composable private fun PreviewView( preview: ImportPreview, vaultUnlocked: Boolean, onConfirm: (ImportMode, String?) -> Unit, onCancel: () -> Unit, ) { var mode by remember { mutableStateOf(ImportMode.MERGE) } var newPassword by remember { mutableStateOf("") } var newConfirm by remember { mutableStateOf("") } val createScore = remember(newPassword) { scorePassword(newPassword) } GlassCard { Text( "Detected: ${preview.sourceLabel} export", color = LocalPeachAccent.current, style = PeachType.labelMono, ) Spacer(Modifier.size(PeachSpacing.sm)) Text( "Found ${preview.entries.size} ${if (preview.entries.size == 1) "entry" else "entries"}", color = peachColors.T0, style = TextStyle(fontFamily = PeachType.Display, fontStyle = FontStyle.Italic, fontSize = 22.sp), ) Spacer(Modifier.size(PeachSpacing.md)) // Up to five entry names, scrollable if more. Column( modifier = Modifier .fillMaxWidth() .heightIn(max = 150.dp) .verticalScroll(rememberScrollState()), ) { preview.entries.take(5).forEach { e -> Text( e.title, color = peachColors.T1, style = PeachType.entryNameList, modifier = Modifier.padding(vertical = 4.dp), ) } if (preview.entries.size > 5) { Text( "+${preview.entries.size - 5} more", color = peachColors.T2, style = PeachType.tinyMono, modifier = Modifier.padding(top = 4.dp), ) } } Divider() if (vaultUnlocked) { RadioRow("Merge with existing vault", selected = mode == ImportMode.MERGE) { mode = ImportMode.MERGE } Spacer(Modifier.size(PeachSpacing.sm)) RadioRow("Replace vault", selected = mode == ImportMode.REPLACE) { mode = ImportMode.REPLACE } } else { FieldLabel("No vault yet — create one to restore into") Spacer(Modifier.size(PeachSpacing.sm)) PeachUnderlineField( label = "Master password", value = newPassword, onValueChange = { newPassword = it }, isPassword = true, ) Spacer(Modifier.size(PeachSpacing.md)) PeachUnderlineField( label = "Confirm password", value = newConfirm, onValueChange = { newConfirm = it }, isPassword = true, ) if (newPassword.isNotEmpty()) { Spacer(Modifier.size(PeachSpacing.sm)) Text( "strength: ${strengthLabel(createScore)}", color = strengthColor(createScore), style = PeachType.labelMono, ) } } } Spacer(Modifier.size(PeachSpacing.lg)) val confirmEnabled = vaultUnlocked || canSubmitCreateVault(newPassword, newConfirm, createScore) AccentButton( label = "Confirm Import", enabled = confirmEnabled, onClick = { onConfirm(mode, if (vaultUnlocked) null else newPassword) }, ) Spacer(Modifier.size(PeachSpacing.sm)) GhostButton("Cancel", peachColors.T1, onCancel) } @Composable private fun RadioRow(label: String, selected: Boolean, onClick: () -> Unit) { val accent = LocalPeachAccent.current Row( verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth().clickable(onClick = onClick).padding(vertical = 4.dp), ) { Box( modifier = Modifier .size(16.dp) .clip(RoundedCornerShape(999.dp)) .border(1.5.dp, if (selected) accent else peachColors.T2, RoundedCornerShape(999.dp)), contentAlignment = Alignment.Center, ) { if (selected) { Box(Modifier.size(8.dp).clip(RoundedCornerShape(999.dp)).background(accent)) } } Spacer(Modifier.width(12.dp)) Text(label, color = peachColors.T0, style = PeachType.body) } } // ---- Done / Working / Error ---------------------------------------------- @Composable private fun DoneView(count: Int, onDone: () -> Unit) { GlassCard { Text( "imported.", color = peachColors.T0, style = TextStyle(fontFamily = PeachType.Display, fontStyle = FontStyle.Italic, fontSize = 24.sp), ) Spacer(Modifier.size(PeachSpacing.sm)) Text( "$count ${if (count == 1) "entry" else "entries"} imported.", color = peachColors.T1, style = PeachType.body, ) } Spacer(Modifier.size(PeachSpacing.lg)) AccentButton(label = "Done", onClick = onDone) } @Composable private fun WorkingView(step: String? = null) { Column( Modifier.fillMaxWidth().padding(vertical = 24.dp), horizontalAlignment = Alignment.CenterHorizontally, ) { Text( step ?: "working…", color = peachColors.T1, style = TextStyle(fontFamily = PeachType.Display, fontStyle = FontStyle.Italic, fontSize = 18.sp), ) Spacer(Modifier.size(PeachSpacing.md)) // Indeterminate: a real vault's write is one batched operation, so // there is no honest per-entry percentage to report. LinearProgressIndicator( modifier = Modifier.fillMaxWidth(), color = LocalPeachAccent.current, trackColor = peachColors.T3, ) Spacer(Modifier.size(PeachSpacing.sm)) Text( "keep Peach open — this can take a moment for a large vault.", color = peachColors.T2, style = PeachType.hint, ) } } @Composable private fun ErrorBanner(message: String) { Box( modifier = Modifier .fillMaxWidth() .clip(RoundedCornerShape(12.dp)) .background(peachColors.BreachBg) .border(1.dp, peachColors.BreachBorder, RoundedCornerShape(12.dp)) .padding(14.dp), ) { Text(message, color = peachColors.Breach, style = PeachType.bodyMono) } } // ---- Shared building blocks (private to this draft) ----------------------- @Composable private fun FieldLabel(text: String) { Text( text.uppercase(), color = peachColors.T2, style = PeachType.labelMono, ) } @Composable private fun PeachMultilineField( value: String, onValueChange: (String) -> Unit, minLines: Int, ) { val accent = LocalPeachAccent.current Box( modifier = Modifier .fillMaxWidth() .clip(RoundedCornerShape(12.dp)) .background(peachColors.S2) .border(1.dp, peachColors.T3, RoundedCornerShape(12.dp)) .padding(14.dp), ) { BasicTextField( value = value, onValueChange = onValueChange, // PeachScript lives in the PUA block (U+E000..U+E0FF). Without the // PeachScript font Android falls back to a font that also maps that // block — Noto CJK — so a pasted backup rendered as Chinese glyphs. textStyle = PeachType.bodyMono.copy(color = peachColors.T0, fontFamily = PeachType.Script), cursorBrush = SolidColor(accent), minLines = minLines, modifier = Modifier.fillMaxWidth(), ) if (value.isEmpty()) { Text( "{ \"peachscript\": 1, … }", color = peachColors.T2, style = PeachType.bodyMono, ) } } } @Composable private fun PeachUnderlineField( label: String, value: String, onValueChange: (String) -> Unit, isPassword: Boolean = false, ) { DesignPeachField( label = label, value = value, onValueChange = onValueChange, visualTransformation = if (isPassword) PasswordVisualTransformation() else androidx.compose.ui.text.input.VisualTransformation.None, ) } @Composable private fun GlassCard(content: @Composable () -> Unit) { Column( modifier = Modifier .fillMaxWidth() .clip(RoundedCornerShape(18.dp)) .background(peachColors.SurfaceElevated) .border(1.dp, peachColors.T3, RoundedCornerShape(18.dp)) .padding(20.dp), content = { content() }, ) } @Composable private fun Divider() { Box(Modifier.fillMaxWidth().padding(vertical = 14.dp).size(1.dp).background(peachColors.T3)) } @Composable private fun GhostButton(label: String, color: Color, onClick: () -> Unit) { Box( modifier = Modifier .fillMaxWidth() .clip(RoundedCornerShape(12.dp)) .border(1.dp, color.copy(alpha = 0.4f), RoundedCornerShape(12.dp)) .clickable(onClick = onClick) .padding(vertical = 12.dp), contentAlignment = Alignment.Center, ) { Text(label, color = color, style = PeachType.bodyMono) } } @Composable private fun AccentButton(label: String, onClick: () -> Unit, enabled: Boolean = true) { val accent = LocalPeachAccent.current Box( modifier = Modifier .fillMaxWidth() .clip(RoundedCornerShape(12.dp)) .background(if (enabled) accent else accent.copy(alpha = 0.3f)) .clickable(enabled = enabled, onClick = onClick) .padding(vertical = 14.dp), contentAlignment = Alignment.Center, ) { Text(label, color = Color(0xFF0C0A06), style = PeachType.bodyMono) } }