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) } }61 val shareLauncher = rememberLauncherForActivityResult(62 ActivityResultContracts.StartActivityForResult(),63 ) { vm.discardSharedPdf() }6465 SecureFlagEffect() 6667 ShellBackground {68 Column(Modifier.fillMaxSize().systemBarsPadding().padding(horizontal = PeachSpacing.xl)) {69 Spacer(Modifier.height(PeachSpacing.md))70 CodexTopBar("Codex backup", onBack)71 Spacer(Modifier.height(PeachSpacing.sm))72 Text(73 "A printable PDF of Aztec codes that encodes your encrypted vault. Keep it with your recovery words — together they restore everything, even offline.",74 color = peachColors.T2, fontFamily = PeachType.Body, fontSize = 13.sp,75 )76 Spacer(Modifier.height(PeachSpacing.xxl))7778 when (val s = state) {79 is GenerateState.Idle -> {80 when (codexGenerateGate(vm.isPro, vm.recoveryWordsSet)) {81 CodexGenerateGate.PRO_REQUIRED -> {82 PeachEmptyOrEndState(83 message = "Codex generation is a Pro feature",84 detail = "Restoring from an existing Codex is always free. Creating a new printable Codex backup requires Peach Pro.",85 action = {86 CodexButton("Open Pro", accent, filled = true, onClick = onUpgradePro)87 },88 )89 }90 CodexGenerateGate.RECOVERY_REQUIRED -> {91 PeachEmptyOrEndState(92 message = "Set up recovery words first",93 detail = "A Codex backup only restores alongside your recovery words. Set them up before generating one.",94 action = {95 CodexButton("Set up recovery words", accent, filled = true, onClick = onSetupRecovery)96 },97 )98 }99 CodexGenerateGate.READY -> {100 var w3 by remember { mutableStateOf("") }101 var w7 by remember { mutableStateOf("") }102 var w11 by remember { mutableStateOf("") }103 val ok = vm.gateMatches(w3, w7, w11)104 Text("Confirm your recovery words", color = peachColors.T1, fontFamily = PeachType.Display, fontStyle = FontStyle.Italic, fontSize = 19.sp)105 Spacer(Modifier.height(PeachSpacing.xs))106 Text("Enter the words at positions 3, 7, and 11.", color = peachColors.T2, fontFamily = PeachType.Body, fontSize = 13.sp)107 Spacer(Modifier.height(PeachSpacing.lg))108 PeachField("Word #3", w3, { w3 = it })109 Spacer(Modifier.height(PeachSpacing.md))110 PeachField("Word #7", w7, { w7 = it })111 Spacer(Modifier.height(PeachSpacing.md))112 PeachField("Word #11", w11, { w11 = it })113 Spacer(Modifier.height(PeachSpacing.xl))114 CodexButton(115 "Generate Codex PDF",116 if (ok) accent else peachColors.T2,117 filled = ok,118 ) { if (ok) vm.startGenerate(System.currentTimeMillis()) }119 }120 }121 }122123 is GenerateState.Running -> Column {124 Text(stepLabel(s.step), color = peachColors.T1, fontFamily = PeachType.Mono, fontSize = 11.sp)125 Spacer(Modifier.height(PeachSpacing.sm))126 val frac = if (s.total > 0) s.current.toFloat() / s.total else 0f127 LinearProgressIndicator(128 progress = { if (s.step == CodexStep.AZTEC) frac else 0.5f },129 modifier = Modifier.fillMaxWidth().height(4.dp),130 color = accent, trackColor = peachColors.S2,131 )132 if (s.step == CodexStep.AZTEC) {133 Spacer(Modifier.height(PeachSpacing.xs))134 Text("${s.current} / ${s.total} codes", color = peachColors.T3, fontFamily = PeachType.Mono, fontSize = 9.sp)135 }136 }137138 is GenerateState.Done -> Column {139 SummaryRow("verify code", s.manifest.verifyCode, accent)140 SummaryRow("entries", s.manifest.entryCount.toString(), accent)141 SummaryRow("codes", "${s.manifest.totalChunks} (${s.manifest.parityChunks} recovery)", accent)142 SummaryRow("pages", s.manifest.pageCount.toString(), accent)143 Spacer(Modifier.height(PeachSpacing.xl))144 CodexButton("Save PDF…", accent, filled = true) {145 vm.expectTransientBackground()146 saveLauncher.launch(codexPdfFileName())147 }148 Spacer(Modifier.height(PeachSpacing.sm))149 CodexButton("Share PDF…", peachColors.T2, filled = false) {150 val uri = vm.sharePdfUri() ?: return@CodexButton151 vm.expectTransientBackground()152 val intent = Intent(Intent.ACTION_SEND).apply {153 type = "application/pdf"154 putExtra(Intent.EXTRA_STREAM, uri)155 addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)156 }157 158 159 shareLauncher.launch(Intent.createChooser(intent, "Share Codex PDF"))160 }161 Spacer(Modifier.height(PeachSpacing.sm))162 CodexButton("Generate again", peachColors.T2, filled = false) { vm.startGenerate(System.currentTimeMillis()) }163 }164165 is GenerateState.Error -> PeachEmptyOrEndState(166 message = "Couldn't generate",167 detail = s.message,168 action = {169 CodexButton("Try again", accent, filled = true) { vm.startGenerate(System.currentTimeMillis()) }170 },171 )172 }173 }174 }175}176177internal enum class CodexGenerateGate { PRO_REQUIRED, RECOVERY_REQUIRED, READY }178179internal fun codexGenerateGate(isPro: Boolean, recoveryWordsSet: Boolean): CodexGenerateGate = when {180 !isPro -> CodexGenerateGate.PRO_REQUIRED181 !recoveryWordsSet -> CodexGenerateGate.RECOVERY_REQUIRED182 else -> CodexGenerateGate.READY183}184185internal fun codexPdfFileName(date: LocalDate = LocalDate.now()): String =186 "peach-codex-$date.pdf"187188private fun stepLabel(step: CodexStep): String = when (step) {189 CodexStep.COMPRESS -> "compressing vault…"190 CodexStep.ERASURE -> "adding recovery codes…"191 CodexStep.AZTEC -> "drawing Aztec codes…"192 CodexStep.VERIFY -> "verifying it rebuilds…"193 CodexStep.PDF -> "laying out the page…"194}195196@Composable197internal fun CodexTopBar(title: String, onBack: () -> Unit) {198 PeachSectionHeader(section = title, onHome = onBack)199}200201@Composable202private fun SummaryRow(label: String, value: String, accent: Color) {203 Row(Modifier.fillMaxWidth().padding(vertical = PeachSpacing.sm), horizontalArrangement = Arrangement.SpaceBetween) {204 Text(label, color = peachColors.T2, fontFamily = PeachType.Mono, fontSize = 10.sp)205 Text(value, color = accent, fontFamily = PeachType.Mono, fontSize = 12.sp)206 }207}208209@Composable210internal fun CodexButton(label: String, color: Color, filled: Boolean, onClick: () -> Unit) {211 val accent = LocalPeachAccent.current212 Box(213 modifier = Modifier.fillMaxWidth().clip(RoundedCornerShape(14.dp))214 .background(if (filled) accent.copy(alpha = 0.14f) else Color.Transparent)215 .clickable(onClick = onClick).padding(vertical = 15.dp),216 contentAlignment = Alignment.Center,217 ) {218 Text(label, color = if (filled) accent else color, fontFamily = PeachType.Mono, fontSize = 13.sp)219 }220}