1package com.peachpasswords.ui.codex23import android.content.Intent4import androidx.activity.compose.rememberLauncherForActivityResult5import androidx.activity.result.contract.ActivityResultContracts6import androidx.compose.foundation.background7import androidx.compose.foundation.clickable8import androidx.compose.foundation.layout.Arrangement9import androidx.compose.foundation.layout.Box10import androidx.compose.foundation.layout.Column11import androidx.compose.foundation.layout.Row12import androidx.compose.foundation.layout.Spacer13import androidx.compose.foundation.layout.fillMaxSize14import androidx.compose.foundation.layout.fillMaxWidth15import androidx.compose.foundation.layout.height16import androidx.compose.foundation.layout.padding17import androidx.compose.foundation.layout.systemBarsPadding18import androidx.compose.foundation.shape.RoundedCornerShape19import androidx.compose.material3.LinearProgressIndicator20import androidx.compose.material3.Text21import androidx.compose.runtime.Composable22import androidx.compose.runtime.getValue23import androidx.compose.runtime.mutableStateOf24import androidx.compose.runtime.remember25import androidx.compose.runtime.setValue26import androidx.compose.ui.Alignment27import androidx.compose.ui.Modifier28import androidx.compose.ui.draw.clip29import androidx.compose.ui.graphics.Color30import androidx.compose.ui.platform.LocalContext31import androidx.compose.ui.text.font.FontStyle32import androidx.compose.ui.unit.dp33import androidx.compose.ui.unit.sp34import androidx.hilt.navigation.compose.hiltViewModel35import androidx.lifecycle.compose.collectAsStateWithLifecycle36import com.peachpasswords.codex.CodexStep37import com.peachpasswords.ui.components.PeachEmptyOrEndState38import com.peachpasswords.ui.components.PeachField39import com.peachpasswords.ui.components.PeachSectionHeader40import com.peachpasswords.ui.components.SecureFlagEffect41import com.peachpasswords.ui.theme.LocalPeachAccent42import com.peachpasswords.ui.theme.PeachSpacing43import com.peachpasswords.ui.theme.PeachType44import com.peachpasswords.ui.theme.ShellBackground45import com.peachpasswords.ui.theme.peachColors46import java.time.LocalDate4748@Composable49fun CodexGenerateScreen(50 onBack: () -> Unit,51 onSetupRecovery: () -> Unit = {},52 onUpgradePro: () -> Unit = {},53 vm: CodexViewModel = hiltViewModel(),54) {55 val state by vm.generate.collectAsStateWithLifecycle()56 val accent = LocalPeachAccent.current57 val context = LocalContext.current58 val saveLauncher = rememberLauncherForActivityResult(59 ActivityResultContracts.CreateDocument("application/pdf"),60 ) { uri -> uri?.let { vm.writePdf(it) } }6162 SecureFlagEffect() 6364 ShellBackground {65 Column(Modifier.fillMaxSize().systemBarsPadding().padding(horizontal = PeachSpacing.xl)) {66 Spacer(Modifier.height(PeachSpacing.md))67 CodexTopBar("Codex backup", onBack)68 Spacer(Modifier.height(PeachSpacing.sm))69 Text(70 "A printable PDF of Aztec codes that encodes your encrypted vault. Keep it with your recovery words — together they restore everything, even offline.",71 color = peachColors.T2, fontFamily = PeachType.Body, fontSize = 13.sp,72 )73 Spacer(Modifier.height(PeachSpacing.xxl))7475 when (val s = state) {76 is GenerateState.Idle -> {77 when (codexGenerateGate(vm.isPro, vm.recoveryWordsSet)) {78 CodexGenerateGate.PRO_REQUIRED -> {79 PeachEmptyOrEndState(80 message = "Codex generation is a Pro feature",81 detail = "Restoring from an existing Codex is always free. Creating a new printable Codex backup requires Peach Pro.",82 action = {83 CodexButton("Open Pro", accent, filled = true, onClick = onUpgradePro)84 },85 )86 }87 CodexGenerateGate.RECOVERY_REQUIRED -> {88 PeachEmptyOrEndState(89 message = "Set up recovery words first",90 detail = "A Codex backup only restores alongside your recovery words. Set them up before generating one.",91 action = {92 CodexButton("Set up recovery words", accent, filled = true, onClick = onSetupRecovery)93 },94 )95 }96 CodexGenerateGate.READY -> {97 var w3 by remember { mutableStateOf("") }98 var w7 by remember { mutableStateOf("") }99 var w11 by remember { mutableStateOf("") }100 val ok = vm.gateMatches(w3, w7, w11)101 Text("Confirm your recovery words", color = peachColors.T1, fontFamily = PeachType.Display, fontStyle = FontStyle.Italic, fontSize = 19.sp)102 Spacer(Modifier.height(PeachSpacing.xs))103 Text("Enter the words at positions 3, 7, and 11.", color = peachColors.T2, fontFamily = PeachType.Body, fontSize = 13.sp)104 Spacer(Modifier.height(PeachSpacing.lg))105 PeachField("Word #3", w3, { w3 = it })106 Spacer(Modifier.height(PeachSpacing.md))107 PeachField("Word #7", w7, { w7 = it })108 Spacer(Modifier.height(PeachSpacing.md))109 PeachField("Word #11", w11, { w11 = it })110 Spacer(Modifier.height(PeachSpacing.xl))111 CodexButton(112 "Generate Codex PDF",113 if (ok) accent else peachColors.T2,114 filled = ok,115 ) { if (ok) vm.startGenerate(System.currentTimeMillis()) }116 }117 }118 }119120 is GenerateState.Running -> Column {121 Text(stepLabel(s.step), color = peachColors.T1, fontFamily = PeachType.Mono, fontSize = 11.sp)122 Spacer(Modifier.height(PeachSpacing.sm))123 val frac = if (s.total > 0) s.current.toFloat() / s.total else 0f124 LinearProgressIndicator(125 progress = { if (s.step == CodexStep.AZTEC) frac else 0.5f },126 modifier = Modifier.fillMaxWidth().height(4.dp),127 color = accent, trackColor = peachColors.S2,128 )129 if (s.step == CodexStep.AZTEC) {130 Spacer(Modifier.height(PeachSpacing.xs))131 Text("${s.current} / ${s.total} codes", color = peachColors.T3, fontFamily = PeachType.Mono, fontSize = 9.sp)132 }133 }134135 is GenerateState.Done -> Column {136 SummaryRow("verify code", s.manifest.verifyCode, accent)137 SummaryRow("entries", s.manifest.entryCount.toString(), accent)138 SummaryRow("codes", "${s.manifest.totalChunks} (${s.manifest.parityChunks} recovery)", accent)139 SummaryRow("pages", s.manifest.pageCount.toString(), accent)140 Spacer(Modifier.height(PeachSpacing.xl))141 CodexButton("Save PDF…", accent, filled = true) {142 vm.expectTransientBackground()143 saveLauncher.launch(codexPdfFileName())144 }145 Spacer(Modifier.height(PeachSpacing.sm))146 CodexButton("Share PDF…", peachColors.T2, filled = false) {147 val uri = vm.sharePdfUri() ?: return@CodexButton148 vm.expectTransientBackground()149 val intent = Intent(Intent.ACTION_SEND).apply {150 type = "application/pdf"151 putExtra(Intent.EXTRA_STREAM, uri)152 addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)153 }154 context.startActivity(Intent.createChooser(intent, "Share Codex PDF"))155 }156 Spacer(Modifier.height(PeachSpacing.sm))157 CodexButton("Generate again", peachColors.T2, filled = false) { vm.startGenerate(System.currentTimeMillis()) }158 }159160 is GenerateState.Error -> PeachEmptyOrEndState(161 message = "Couldn't generate",162 detail = s.message,163 action = {164 CodexButton("Try again", accent, filled = true) { vm.startGenerate(System.currentTimeMillis()) }165 },166 )167 }168 }169 }170}171172internal enum class CodexGenerateGate { PRO_REQUIRED, RECOVERY_REQUIRED, READY }173174internal fun codexGenerateGate(isPro: Boolean, recoveryWordsSet: Boolean): CodexGenerateGate = when {175 !isPro -> CodexGenerateGate.PRO_REQUIRED176 !recoveryWordsSet -> CodexGenerateGate.RECOVERY_REQUIRED177 else -> CodexGenerateGate.READY178}179180internal fun codexPdfFileName(date: LocalDate = LocalDate.now()): String =181 "peach-codex-$date.pdf"182183private fun stepLabel(step: CodexStep): String = when (step) {184 CodexStep.COMPRESS -> "compressing vault…"185 CodexStep.ERASURE -> "adding recovery codes…"186 CodexStep.AZTEC -> "drawing Aztec codes…"187 CodexStep.VERIFY -> "verifying it rebuilds…"188 CodexStep.PDF -> "laying out the page…"189}190191@Composable192internal fun CodexTopBar(title: String, onBack: () -> Unit) {193 PeachSectionHeader(section = title, onHome = onBack)194}195196@Composable197private fun SummaryRow(label: String, value: String, accent: Color) {198 Row(Modifier.fillMaxWidth().padding(vertical = PeachSpacing.sm), horizontalArrangement = Arrangement.SpaceBetween) {199 Text(label, color = peachColors.T2, fontFamily = PeachType.Mono, fontSize = 10.sp)200 Text(value, color = accent, fontFamily = PeachType.Mono, fontSize = 12.sp)201 }202}203204@Composable205internal fun CodexButton(label: String, color: Color, filled: Boolean, onClick: () -> Unit) {206 val accent = LocalPeachAccent.current207 Box(208 modifier = Modifier.fillMaxWidth().clip(RoundedCornerShape(14.dp))209 .background(if (filled) accent.copy(alpha = 0.14f) else Color.Transparent)210 .clickable(onClick = onClick).padding(vertical = 15.dp),211 contentAlignment = Alignment.Center,212 ) {213 Text(label, color = if (filled) accent else color, fontFamily = PeachType.Mono, fontSize = 13.sp)214 }215}