package com.peachpasswords.ui.codex import android.content.Intent import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.foundation.background 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.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.systemBarsPadding import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.LinearProgressIndicator import androidx.compose.material3.Text import androidx.compose.runtime.Composable 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.platform.LocalContext import androidx.compose.ui.text.font.FontStyle import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.peachpasswords.codex.CodexStep import com.peachpasswords.ui.components.PeachEmptyOrEndState import com.peachpasswords.ui.components.PeachField import com.peachpasswords.ui.components.PeachSectionHeader import com.peachpasswords.ui.components.SecureFlagEffect import com.peachpasswords.ui.theme.LocalPeachAccent import com.peachpasswords.ui.theme.PeachSpacing import com.peachpasswords.ui.theme.PeachType import com.peachpasswords.ui.theme.ShellBackground import com.peachpasswords.ui.theme.peachColors import java.time.LocalDate @Composable fun CodexGenerateScreen( onBack: () -> Unit, onSetupRecovery: () -> Unit = {}, onUpgradePro: () -> Unit = {}, vm: CodexViewModel = hiltViewModel(), ) { val state by vm.generate.collectAsStateWithLifecycle() val accent = LocalPeachAccent.current val context = LocalContext.current val saveLauncher = rememberLauncherForActivityResult( ActivityResultContracts.CreateDocument("application/pdf"), ) { uri -> uri?.let { vm.writePdf(it) } } val shareLauncher = rememberLauncherForActivityResult( ActivityResultContracts.StartActivityForResult(), ) { vm.discardSharedPdf() } SecureFlagEffect() // MN-3: never screenshot the codex generation / verify code ShellBackground { Column(Modifier.fillMaxSize().systemBarsPadding().padding(horizontal = PeachSpacing.xl)) { Spacer(Modifier.height(PeachSpacing.md)) CodexTopBar("Codex backup", onBack) Spacer(Modifier.height(PeachSpacing.sm)) Text( "A printable PDF of Aztec codes that encodes your encrypted vault. Keep it with your recovery words — together they restore everything, even offline.", color = peachColors.T2, fontFamily = PeachType.Body, fontSize = 13.sp, ) Spacer(Modifier.height(PeachSpacing.xxl)) when (val s = state) { is GenerateState.Idle -> { when (codexGenerateGate(vm.isPro, vm.recoveryWordsSet)) { CodexGenerateGate.PRO_REQUIRED -> { PeachEmptyOrEndState( message = "Codex generation is a Pro feature", detail = "Restoring from an existing Codex is always free. Creating a new printable Codex backup requires Peach Pro.", action = { CodexButton("Open Pro", accent, filled = true, onClick = onUpgradePro) }, ) } CodexGenerateGate.RECOVERY_REQUIRED -> { PeachEmptyOrEndState( message = "Set up recovery words first", detail = "A Codex backup only restores alongside your recovery words. Set them up before generating one.", action = { CodexButton("Set up recovery words", accent, filled = true, onClick = onSetupRecovery) }, ) } CodexGenerateGate.READY -> { var w3 by remember { mutableStateOf("") } var w7 by remember { mutableStateOf("") } var w11 by remember { mutableStateOf("") } val ok = vm.gateMatches(w3, w7, w11) Text("Confirm your recovery words", color = peachColors.T1, fontFamily = PeachType.Display, fontStyle = FontStyle.Italic, fontSize = 19.sp) Spacer(Modifier.height(PeachSpacing.xs)) Text("Enter the words at positions 3, 7, and 11.", color = peachColors.T2, fontFamily = PeachType.Body, fontSize = 13.sp) Spacer(Modifier.height(PeachSpacing.lg)) PeachField("Word #3", w3, { w3 = it }) Spacer(Modifier.height(PeachSpacing.md)) PeachField("Word #7", w7, { w7 = it }) Spacer(Modifier.height(PeachSpacing.md)) PeachField("Word #11", w11, { w11 = it }) Spacer(Modifier.height(PeachSpacing.xl)) CodexButton( "Generate Codex PDF", if (ok) accent else peachColors.T2, filled = ok, ) { if (ok) vm.startGenerate(System.currentTimeMillis()) } } } } is GenerateState.Running -> Column { Text(stepLabel(s.step), color = peachColors.T1, fontFamily = PeachType.Mono, fontSize = 11.sp) Spacer(Modifier.height(PeachSpacing.sm)) val frac = if (s.total > 0) s.current.toFloat() / s.total else 0f LinearProgressIndicator( progress = { if (s.step == CodexStep.AZTEC) frac else 0.5f }, modifier = Modifier.fillMaxWidth().height(4.dp), color = accent, trackColor = peachColors.S2, ) if (s.step == CodexStep.AZTEC) { Spacer(Modifier.height(PeachSpacing.xs)) Text("${s.current} / ${s.total} codes", color = peachColors.T3, fontFamily = PeachType.Mono, fontSize = 9.sp) } } is GenerateState.Done -> Column { SummaryRow("verify code", s.manifest.verifyCode, accent) SummaryRow("entries", s.manifest.entryCount.toString(), accent) SummaryRow("codes", "${s.manifest.totalChunks} (${s.manifest.parityChunks} recovery)", accent) SummaryRow("pages", s.manifest.pageCount.toString(), accent) Spacer(Modifier.height(PeachSpacing.xl)) CodexButton("Save PDF…", accent, filled = true) { vm.expectTransientBackground() saveLauncher.launch(codexPdfFileName()) } Spacer(Modifier.height(PeachSpacing.sm)) CodexButton("Share PDF…", peachColors.T2, filled = false) { val uri = vm.sharePdfUri() ?: return@CodexButton vm.expectTransientBackground() val intent = Intent(Intent.ACTION_SEND).apply { type = "application/pdf" putExtra(Intent.EXTRA_STREAM, uri) addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) } // Launch for result so the cached artefact can be removed // as soon as the chooser returns (PB-26-137). shareLauncher.launch(Intent.createChooser(intent, "Share Codex PDF")) } Spacer(Modifier.height(PeachSpacing.sm)) CodexButton("Generate again", peachColors.T2, filled = false) { vm.startGenerate(System.currentTimeMillis()) } } is GenerateState.Error -> PeachEmptyOrEndState( message = "Couldn't generate", detail = s.message, action = { CodexButton("Try again", accent, filled = true) { vm.startGenerate(System.currentTimeMillis()) } }, ) } } } } internal enum class CodexGenerateGate { PRO_REQUIRED, RECOVERY_REQUIRED, READY } internal fun codexGenerateGate(isPro: Boolean, recoveryWordsSet: Boolean): CodexGenerateGate = when { !isPro -> CodexGenerateGate.PRO_REQUIRED !recoveryWordsSet -> CodexGenerateGate.RECOVERY_REQUIRED else -> CodexGenerateGate.READY } internal fun codexPdfFileName(date: LocalDate = LocalDate.now()): String = "peach-codex-$date.pdf" private fun stepLabel(step: CodexStep): String = when (step) { CodexStep.COMPRESS -> "compressing vault…" CodexStep.ERASURE -> "adding recovery codes…" CodexStep.AZTEC -> "drawing Aztec codes…" CodexStep.VERIFY -> "verifying it rebuilds…" CodexStep.PDF -> "laying out the page…" } @Composable internal fun CodexTopBar(title: String, onBack: () -> Unit) { PeachSectionHeader(section = title, onHome = onBack) } @Composable private fun SummaryRow(label: String, value: String, accent: Color) { Row(Modifier.fillMaxWidth().padding(vertical = PeachSpacing.sm), horizontalArrangement = Arrangement.SpaceBetween) { Text(label, color = peachColors.T2, fontFamily = PeachType.Mono, fontSize = 10.sp) Text(value, color = accent, fontFamily = PeachType.Mono, fontSize = 12.sp) } } @Composable internal fun CodexButton(label: String, color: Color, filled: Boolean, onClick: () -> Unit) { val accent = LocalPeachAccent.current Box( modifier = Modifier.fillMaxWidth().clip(RoundedCornerShape(14.dp)) .background(if (filled) accent.copy(alpha = 0.14f) else Color.Transparent) .clickable(onClick = onClick).padding(vertical = 15.dp), contentAlignment = Alignment.Center, ) { Text(label, color = if (filled) accent else color, fontFamily = PeachType.Mono, fontSize = 13.sp) } }