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.shape.RoundedCornerShape18import androidx.compose.material3.LinearProgressIndicator19import androidx.compose.material3.Text20import androidx.compose.runtime.Composable21import androidx.compose.runtime.getValue22import androidx.compose.runtime.mutableStateOf23import androidx.compose.runtime.remember24import androidx.compose.runtime.setValue25import androidx.compose.ui.Alignment26import androidx.compose.ui.Modifier27import androidx.compose.ui.draw.clip28import androidx.compose.ui.graphics.Color29import androidx.compose.ui.platform.LocalContext30import androidx.compose.ui.text.font.FontStyle31import androidx.compose.ui.unit.dp32import androidx.compose.ui.unit.sp33import androidx.hilt.navigation.compose.hiltViewModel34import androidx.lifecycle.compose.collectAsStateWithLifecycle35import com.peachpasswords.codex.CodexStep36import com.peachpasswords.ui.components.PeachEmptyOrEndState37import com.peachpasswords.ui.components.PeachField38import com.peachpasswords.ui.components.PeachTopBar39import com.peachpasswords.ui.components.SecureFlagEffect40import com.peachpasswords.ui.theme.LocalPeachAccent41import com.peachpasswords.ui.theme.PeachSpacing42import com.peachpasswords.ui.theme.PeachType43import com.peachpasswords.ui.theme.ShellBackground44import com.peachpasswords.ui.theme.peachColors45import java.time.LocalDate4647@Composable48fun CodexGenerateScreen(49 onBack: () -> Unit,50 onSetupRecovery: () -> Unit = {},51 onUpgradePro: () -> Unit = {},52 vm: CodexViewModel = hiltViewModel(),53) {54 val state by vm.generate.collectAsStateWithLifecycle()55 val accent = LocalPeachAccent.current56 val context = LocalContext.current57 val saveLauncher = rememberLauncherForActivityResult(58 ActivityResultContracts.CreateDocument("application/pdf"),59 ) { uri -> uri?.let { vm.writePdf(it) } }6061 SecureFlagEffect() 6263 ShellBackground {64 Column(Modifier.fillMaxSize().padding(horizontal = PeachSpacing.xl)) {65 Spacer(Modifier.height(PeachSpacing.md))66 CodexTopBar("Codex backup", onBack)67 Spacer(Modifier.height(PeachSpacing.sm))68 Text(69 "A printable PDF of Aztec codes that encodes your encrypted vault. Keep it with your recovery words — together they restore everything, even offline.",70 color = peachColors.T2, fontFamily = PeachType.Body, fontSize = 13.sp,71 )72 Spacer(Modifier.height(PeachSpacing.xxl))7374 when (val s = state) {75 is GenerateState.Idle -> {76 when (codexGenerateGate(vm.isPro, vm.recoveryWordsSet)) {77 CodexGenerateGate.PRO_REQUIRED -> {78 PeachEmptyOrEndState(79 message = "Codex generation is a Pro feature",80 detail = "Restoring from an existing Codex is always free. Creating a new printable Codex backup requires Peach Pro.",81 action = {82 CodexButton("Open Pro", accent, filled = true, onClick = onUpgradePro)83 },84 )85 }86 CodexGenerateGate.RECOVERY_REQUIRED -> {87 PeachEmptyOrEndState(88 message = "Set up recovery words first",89 detail = "A Codex backup only restores alongside your recovery words. Set them up before generating one.",90 action = {91 CodexButton("Set up recovery words", accent, filled = true, onClick = onSetupRecovery)92 },93 )94 }95 CodexGenerateGate.READY -> {96 var w3 by remember { mutableStateOf("") }97 var w7 by remember { mutableStateOf("") }98 var w11 by remember { mutableStateOf("") }99 val ok = vm.gateMatches(w3, w7, w11)100 Text("Confirm your recovery words", color = peachColors.T1, fontFamily = PeachType.Display, fontStyle = FontStyle.Italic, fontSize = 19.sp)101 Spacer(Modifier.height(PeachSpacing.xs))102 Text("Enter the words at positions 3, 7, and 11.", color = peachColors.T2, fontFamily = PeachType.Body, fontSize = 13.sp)103 Spacer(Modifier.height(PeachSpacing.lg))104 PeachField("Word #3", w3, { w3 = it })105 Spacer(Modifier.height(PeachSpacing.md))106 PeachField("Word #7", w7, { w7 = it })107 Spacer(Modifier.height(PeachSpacing.md))108 PeachField("Word #11", w11, { w11 = it })109 Spacer(Modifier.height(PeachSpacing.xl))110 CodexButton(111 "Generate Codex PDF",112 if (ok) accent else peachColors.T2,113 filled = ok,114 ) { if (ok) vm.startGenerate(System.currentTimeMillis()) }115 }116 }117 }118119 is GenerateState.Running -> Column {120 Text(stepLabel(s.step), color = peachColors.T1, fontFamily = PeachType.Mono, fontSize = 11.sp)121 Spacer(Modifier.height(PeachSpacing.sm))122 val frac = if (s.total > 0) s.current.toFloat() / s.total else 0f123 LinearProgressIndicator(124 progress = { if (s.step == CodexStep.AZTEC) frac else 0.5f },125 modifier = Modifier.fillMaxWidth().height(4.dp),126 color = accent, trackColor = peachColors.S2,127 )128 if (s.step == CodexStep.AZTEC) {129 Spacer(Modifier.height(PeachSpacing.xs))130 Text("${s.current} / ${s.total} codes", color = peachColors.T3, fontFamily = PeachType.Mono, fontSize = 9.sp)131 }132 }133134 is GenerateState.Done -> Column {135 SummaryRow("verify code", s.manifest.verifyCode, accent)136 SummaryRow("entries", s.manifest.entryCount.toString(), accent)137 SummaryRow("codes", "${s.manifest.totalChunks} (${s.manifest.parityChunks} recovery)", accent)138 SummaryRow("pages", s.manifest.pageCount.toString(), accent)139 Spacer(Modifier.height(PeachSpacing.xl))140 CodexButton("Save PDF…", accent, filled = true) { saveLauncher.launch(codexPdfFileName()) }141 Spacer(Modifier.height(PeachSpacing.sm))142 CodexButton("Share PDF…", peachColors.T2, filled = false) {143 val uri = vm.sharePdfUri() ?: return@CodexButton144 val intent = Intent(Intent.ACTION_SEND).apply {145 type = "application/pdf"146 putExtra(Intent.EXTRA_STREAM, uri)147 addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)148 }149 context.startActivity(Intent.createChooser(intent, "Share Codex PDF"))150 }151 Spacer(Modifier.height(PeachSpacing.sm))152 CodexButton("Generate again", peachColors.T2, filled = false) { vm.startGenerate(System.currentTimeMillis()) }153 }154155 is GenerateState.Error -> PeachEmptyOrEndState(156 message = "Couldn't generate",157 detail = s.message,158 action = {159 CodexButton("Try again", accent, filled = true) { vm.startGenerate(System.currentTimeMillis()) }160 },161 )162 }163 }164 }165}166167internal enum class CodexGenerateGate { PRO_REQUIRED, RECOVERY_REQUIRED, READY }168169internal fun codexGenerateGate(isPro: Boolean, recoveryWordsSet: Boolean): CodexGenerateGate = when {170 !isPro -> CodexGenerateGate.PRO_REQUIRED171 !recoveryWordsSet -> CodexGenerateGate.RECOVERY_REQUIRED172 else -> CodexGenerateGate.READY173}174175internal fun codexPdfFileName(date: LocalDate = LocalDate.now()): String =176 "peach-codex-$date.pdf"177178private fun stepLabel(step: CodexStep): String = when (step) {179 CodexStep.COMPRESS -> "compressing vault…"180 CodexStep.ERASURE -> "adding recovery codes…"181 CodexStep.AZTEC -> "drawing Aztec codes…"182 CodexStep.VERIFY -> "verifying it rebuilds…"183 CodexStep.PDF -> "laying out the page…"184}185186@Composable187internal fun CodexTopBar(title: String, onBack: () -> Unit) {188 PeachTopBar(189 title = title,190 leading = {191 Text(192 "‹",193 color = peachColors.T1,194 fontSize = 26.sp,195 modifier = Modifier196 .clip(RoundedCornerShape(999.dp))197 .clickable(onClick = onBack)198 .padding(horizontal = PeachSpacing.sm),199 )200 },201 )202}203204@Composable205private fun SummaryRow(label: String, value: String, accent: Color) {206 Row(Modifier.fillMaxWidth().padding(vertical = PeachSpacing.sm), horizontalArrangement = Arrangement.SpaceBetween) {207 Text(label, color = peachColors.T2, fontFamily = PeachType.Mono, fontSize = 10.sp)208 Text(value, color = accent, fontFamily = PeachType.Mono, fontSize = 12.sp)209 }210}211212@Composable213internal fun CodexButton(label: String, color: Color, filled: Boolean, onClick: () -> Unit) {214 val accent = LocalPeachAccent.current215 Box(216 modifier = Modifier.fillMaxWidth().clip(RoundedCornerShape(14.dp))217 .background(if (filled) accent.copy(alpha = 0.14f) else Color.Transparent)218 .clickable(onClick = onClick).padding(vertical = 15.dp),219 contentAlignment = Alignment.Center,220 ) {221 Text(label, color = if (filled) accent else color, fontFamily = PeachType.Mono, fontSize = 13.sp)222 }223}