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.size18import androidx.compose.foundation.shape.CircleShape19import androidx.compose.foundation.shape.RoundedCornerShape20import androidx.compose.foundation.text.BasicTextField21import androidx.compose.material3.LinearProgressIndicator22import androidx.compose.material3.Text23import androidx.compose.runtime.Composable24import androidx.compose.runtime.getValue25import androidx.compose.runtime.mutableStateOf26import androidx.compose.runtime.remember27import androidx.compose.runtime.setValue28import androidx.compose.ui.Alignment29import androidx.compose.ui.Modifier30import androidx.compose.ui.draw.clip31import androidx.compose.ui.graphics.Color32import androidx.compose.ui.graphics.SolidColor33import androidx.compose.ui.platform.LocalContext34import androidx.compose.ui.text.font.FontStyle35import androidx.compose.ui.unit.dp36import androidx.compose.ui.unit.sp37import androidx.hilt.navigation.compose.hiltViewModel38import androidx.lifecycle.compose.collectAsStateWithLifecycle39import com.peachpasswords.codex.CodexStep40import com.peachpasswords.ui.components.SecureFlagEffect41import com.peachpasswords.ui.theme.LocalPeachAccent42import com.peachpasswords.ui.theme.peachColors43import com.peachpasswords.ui.theme.PeachType44import com.peachpasswords.ui.theme.ShellBackground45import 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 = 24.dp)) {65 CodexHeader("Codex backup", onBack)66 Spacer(Modifier.height(8.dp))67 Text(68 "A printable PDF of Aztec codes that encodes your encrypted vault. Keep it with your recovery words — together they restore everything, even offline.",69 color = peachColors.T2, fontFamily = PeachType.Body, fontSize = 13.sp,70 )71 Spacer(Modifier.height(28.dp))7273 when (val s = state) {74 is GenerateState.Idle -> {75 when (codexGenerateGate(vm.isPro, vm.recoveryWordsSet)) {76 CodexGenerateGate.PRO_REQUIRED -> {77 Text("Codex generation is a Pro feature", color = peachColors.T1, fontFamily = PeachType.Display, fontStyle = FontStyle.Italic, fontSize = 19.sp)78 Spacer(Modifier.height(6.dp))79 Text(80 "Restoring from an existing Codex is always free. Creating a new printable Codex backup requires Peach Pro.",81 color = peachColors.T2, fontFamily = PeachType.Body, fontSize = 13.sp,82 )83 Spacer(Modifier.height(20.dp))84 CodexButton("Open Pro", accent, filled = true, onClick = onUpgradePro)85 }86 CodexGenerateGate.RECOVERY_REQUIRED -> {87 Text("Set up recovery words first", color = peachColors.T1, fontFamily = PeachType.Display, fontStyle = FontStyle.Italic, fontSize = 19.sp)88 Spacer(Modifier.height(6.dp))89 Text(90 "A Codex backup only restores alongside your recovery words. Set them up before generating one.",91 color = peachColors.T2, fontFamily = PeachType.Body, fontSize = 13.sp,92 )93 Spacer(Modifier.height(20.dp))94 CodexButton("Set up recovery words", accent, filled = true, onClick = onSetupRecovery)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(6.dp))103 Text("Enter the words at positions 3, 7, and 11.", color = peachColors.T2, fontFamily = PeachType.Body, fontSize = 13.sp)104 Spacer(Modifier.height(16.dp))105 GateField("Word #3", w3, accent) { w3 = it }106 GateField("Word #7", w7, accent) { w7 = it }107 GateField("Word #11", w11, accent) { w11 = it }108 Spacer(Modifier.height(20.dp))109 CodexButton(110 "Generate Codex PDF",111 if (ok) accent else peachColors.T2,112 filled = ok,113 ) { if (ok) vm.startGenerate(System.currentTimeMillis()) }114 }115 }116 }117118 is GenerateState.Running -> Column {119 Text(stepLabel(s.step), color = peachColors.T1, fontFamily = PeachType.Mono, fontSize = 11.sp)120 Spacer(Modifier.height(10.dp))121 val frac = if (s.total > 0) s.current.toFloat() / s.total else 0f122 LinearProgressIndicator(123 progress = { if (s.step == CodexStep.AZTEC) frac else 0.5f },124 modifier = Modifier.fillMaxWidth().height(4.dp),125 color = accent, trackColor = peachColors.S2,126 )127 if (s.step == CodexStep.AZTEC) {128 Spacer(Modifier.height(6.dp))129 Text("${s.current} / ${s.total} codes", color = peachColors.T3, fontFamily = PeachType.Mono, fontSize = 9.sp)130 }131 }132133 is GenerateState.Done -> Column {134 SummaryRow("verify code", s.manifest.verifyCode, accent)135 SummaryRow("entries", s.manifest.entryCount.toString(), accent)136 SummaryRow("codes", "${s.manifest.totalChunks} (${s.manifest.parityChunks} recovery)", accent)137 SummaryRow("pages", s.manifest.pageCount.toString(), accent)138 Spacer(Modifier.height(24.dp))139 CodexButton("Save PDF…", accent, filled = true) { saveLauncher.launch(codexPdfFileName()) }140 Spacer(Modifier.height(10.dp))141 CodexButton("Share PDF…", peachColors.T2, filled = false) {142 val uri = vm.sharePdfUri() ?: return@CodexButton143 val intent = Intent(Intent.ACTION_SEND).apply {144 type = "application/pdf"145 putExtra(Intent.EXTRA_STREAM, uri)146 addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)147 }148 context.startActivity(Intent.createChooser(intent, "Share Codex PDF"))149 }150 Spacer(Modifier.height(10.dp))151 CodexButton("Generate again", peachColors.T2, filled = false) { vm.startGenerate(System.currentTimeMillis()) }152 }153154 is GenerateState.Error -> Column {155 Text("Couldn't generate", color = peachColors.Breach, fontFamily = PeachType.Display, fontStyle = FontStyle.Italic, fontSize = 19.sp)156 Spacer(Modifier.height(6.dp))157 Text(s.message, color = peachColors.T2, fontFamily = PeachType.Body, fontSize = 13.sp)158 Spacer(Modifier.height(20.dp))159 CodexButton("Try again", accent, filled = true) { vm.startGenerate(System.currentTimeMillis()) }160 }161 }162 }163 }164}165166internal enum class CodexGenerateGate { PRO_REQUIRED, RECOVERY_REQUIRED, READY }167168internal fun codexGenerateGate(isPro: Boolean, recoveryWordsSet: Boolean): CodexGenerateGate = when {169 !isPro -> CodexGenerateGate.PRO_REQUIRED170 !recoveryWordsSet -> CodexGenerateGate.RECOVERY_REQUIRED171 else -> CodexGenerateGate.READY172}173174internal fun codexPdfFileName(date: LocalDate = LocalDate.now()): String =175 "peach-codex-$date.pdf"176177private fun stepLabel(step: CodexStep): String = when (step) {178 CodexStep.COMPRESS -> "compressing vault…"179 CodexStep.ERASURE -> "adding recovery codes…"180 CodexStep.AZTEC -> "drawing Aztec codes…"181 CodexStep.VERIFY -> "verifying it rebuilds…"182 CodexStep.PDF -> "laying out the page…"183}184185@Composable186internal fun CodexHeader(title: String, onBack: () -> Unit) {187 Row(188 modifier = Modifier.fillMaxWidth().padding(top = 12.dp).height(48.dp),189 verticalAlignment = Alignment.CenterVertically,190 ) {191 Text("‹", color = peachColors.T1, fontSize = 26.sp,192 modifier = Modifier.clip(CircleShape).clickable(onClick = onBack).padding(horizontal = 8.dp))193 Spacer(Modifier.size(8.dp))194 Text(title, color = peachColors.T0, fontFamily = PeachType.Display, fontStyle = FontStyle.Italic, fontSize = 19.sp)195 }196}197198@Composable199private fun SummaryRow(label: String, value: String, accent: Color) {200 Row(Modifier.fillMaxWidth().padding(vertical = 7.dp), horizontalArrangement = Arrangement.SpaceBetween) {201 Text(label, color = peachColors.T2, fontFamily = PeachType.Mono, fontSize = 10.sp)202 Text(value, color = accent, fontFamily = PeachType.Mono, fontSize = 12.sp)203 }204}205206@Composable207private fun GateField(label: String, value: String, accent: Color, onChange: (String) -> Unit) {208 Column(Modifier.fillMaxWidth().padding(vertical = 6.dp)) {209 Text(label, color = peachColors.T2, fontFamily = PeachType.Mono, fontSize = 10.sp)210 BasicTextField(211 value = value,212 onValueChange = { onChange(it) },213 singleLine = true,214 textStyle = PeachType.bodyMono.copy(color = peachColors.T0),215 cursorBrush = SolidColor(accent),216 modifier = Modifier.fillMaxWidth().padding(top = 4.dp),217 )218 Box(Modifier.fillMaxWidth().padding(top = 4.dp).height(1.dp).background(peachColors.T3))219 }220}221222@Composable223internal fun CodexButton(label: String, color: Color, filled: Boolean, onClick: () -> Unit) {224 val accent = LocalPeachAccent.current225 Box(226 modifier = Modifier.fillMaxWidth().clip(RoundedCornerShape(14.dp))227 .background(if (filled) accent.copy(alpha = 0.14f) else Color.Transparent)228 .clickable(onClick = onClick).padding(vertical = 15.dp),229 contentAlignment = Alignment.Center,230 ) {231 Text(label, color = if (filled) accent else color, fontFamily = PeachType.Mono, fontSize = 13.sp)232 }233}