CodexGenerateScreen.kt

11.0 KB · raw

1package com.peachpasswords.ui.codex23import androidx.activity.compose.rememberLauncherForActivityResult4import androidx.activity.result.contract.ActivityResultContracts5import androidx.compose.foundation.background6import androidx.compose.foundation.clickable7import androidx.compose.foundation.layout.Arrangement8import androidx.compose.foundation.layout.Box9import androidx.compose.foundation.layout.Column10import androidx.compose.foundation.layout.Row11import androidx.compose.foundation.layout.Spacer12import androidx.compose.foundation.layout.fillMaxSize13import androidx.compose.foundation.layout.fillMaxWidth14import androidx.compose.foundation.layout.height15import androidx.compose.foundation.layout.padding16import androidx.compose.foundation.layout.size17import androidx.compose.foundation.shape.CircleShape18import androidx.compose.foundation.shape.RoundedCornerShape19import androidx.compose.foundation.text.BasicTextField20import androidx.compose.material3.LinearProgressIndicator21import androidx.compose.material3.Text22import androidx.compose.runtime.Composable23import androidx.compose.runtime.getValue24import androidx.compose.runtime.mutableStateOf25import androidx.compose.runtime.remember26import androidx.compose.runtime.setValue27import androidx.compose.ui.Alignment28import androidx.compose.ui.Modifier29import androidx.compose.ui.draw.clip30import androidx.compose.ui.graphics.Color31import androidx.compose.ui.graphics.SolidColor32import androidx.compose.ui.text.font.FontStyle33import androidx.compose.ui.unit.dp34import androidx.compose.ui.unit.sp35import androidx.hilt.navigation.compose.hiltViewModel36import androidx.lifecycle.compose.collectAsStateWithLifecycle37import com.peachpasswords.codex.CodexStep38import com.peachpasswords.ui.components.SecureFlagEffect39import com.peachpasswords.ui.theme.LocalPeachAccent40import com.peachpasswords.ui.theme.peachColors41import com.peachpasswords.ui.theme.PeachType42import com.peachpasswords.ui.theme.ShellBackground43import java.time.LocalDate4445@Composable46fun CodexGenerateScreen(47    onBack: () -> Unit,48    onSetupRecovery: () -> Unit = {},49    onUpgradePro: () -> Unit = {},50    vm: CodexViewModel = hiltViewModel(),51) {52    val state by vm.generate.collectAsStateWithLifecycle()53    val accent = LocalPeachAccent.current54    val saveLauncher = rememberLauncherForActivityResult(55        ActivityResultContracts.CreateDocument("application/pdf"),56    ) { uri -> uri?.let { vm.writePdf(it) } }5758    SecureFlagEffect() // MN-3: never screenshot the codex generation / verify code5960    ShellBackground {61        Column(Modifier.fillMaxSize().padding(horizontal = 24.dp)) {62            CodexHeader("Codex backup", onBack)63            Spacer(Modifier.height(8.dp))64            Text(65                "A printable PDF of Aztec codes that encodes your encrypted vault. Keep it with your recovery words — together they restore everything, even offline.",66                color = peachColors.T2, fontFamily = PeachType.Body, fontSize = 13.sp,67            )68            Spacer(Modifier.height(28.dp))6970            when (val s = state) {71                is GenerateState.Idle -> {72                    when (codexGenerateGate(vm.isPro, vm.recoveryWordsSet)) {73                        CodexGenerateGate.PRO_REQUIRED -> {74                            Text("Codex generation is a Pro feature", color = peachColors.T1, fontFamily = PeachType.Display, fontStyle = FontStyle.Italic, fontSize = 19.sp)75                            Spacer(Modifier.height(6.dp))76                            Text(77                                "Restoring from an existing Codex is always free. Creating a new printable Codex backup requires Peach Pro.",78                                color = peachColors.T2, fontFamily = PeachType.Body, fontSize = 13.sp,79                            )80                            Spacer(Modifier.height(20.dp))81                            CodexButton("Open Pro", accent, filled = true, onClick = onUpgradePro)82                        }83                        CodexGenerateGate.RECOVERY_REQUIRED -> {84                        Text("Set up recovery words first", color = peachColors.T1, fontFamily = PeachType.Display, fontStyle = FontStyle.Italic, fontSize = 19.sp)85                        Spacer(Modifier.height(6.dp))86                        Text(87                            "A Codex backup only restores alongside your recovery words. Set them up before generating one.",88                            color = peachColors.T2, fontFamily = PeachType.Body, fontSize = 13.sp,89                        )90                        Spacer(Modifier.height(20.dp))91                        CodexButton("Set up recovery words", accent, filled = true, onClick = onSetupRecovery)92                        }93                        CodexGenerateGate.READY -> {94                        var w3 by remember { mutableStateOf("") }95                        var w7 by remember { mutableStateOf("") }96                        var w11 by remember { mutableStateOf("") }97                        val ok = vm.gateMatches(w3, w7, w11)98                        Text("Confirm your recovery words", color = peachColors.T1, fontFamily = PeachType.Display, fontStyle = FontStyle.Italic, fontSize = 19.sp)99                        Spacer(Modifier.height(6.dp))100                        Text("Enter the words at positions 3, 7, and 11.", color = peachColors.T2, fontFamily = PeachType.Body, fontSize = 13.sp)101                        Spacer(Modifier.height(16.dp))102                        GateField("Word #3", w3, accent) { w3 = it }103                        GateField("Word #7", w7, accent) { w7 = it }104                        GateField("Word #11", w11, accent) { w11 = it }105                        Spacer(Modifier.height(20.dp))106                        CodexButton(107                            "Generate Codex PDF",108                            if (ok) accent else peachColors.T2,109                            filled = ok,110                        ) { if (ok) vm.startGenerate(System.currentTimeMillis()) }111                        }112                    }113                }114115                is GenerateState.Running -> Column {116                    Text(stepLabel(s.step), color = peachColors.T1, fontFamily = PeachType.Mono, fontSize = 11.sp)117                    Spacer(Modifier.height(10.dp))118                    val frac = if (s.total > 0) s.current.toFloat() / s.total else 0f119                    LinearProgressIndicator(120                        progress = { if (s.step == CodexStep.AZTEC) frac else 0.5f },121                        modifier = Modifier.fillMaxWidth().height(4.dp),122                        color = accent, trackColor = peachColors.S2,123                    )124                    if (s.step == CodexStep.AZTEC) {125                        Spacer(Modifier.height(6.dp))126                        Text("${s.current} / ${s.total} codes", color = peachColors.T3, fontFamily = PeachType.Mono, fontSize = 9.sp)127                    }128                }129130                is GenerateState.Done -> Column {131                    SummaryRow("verify code", s.manifest.verifyCode, accent)132                    SummaryRow("entries", s.manifest.entryCount.toString(), accent)133                    SummaryRow("codes", "${s.manifest.totalChunks} (${s.manifest.parityChunks} recovery)", accent)134                    SummaryRow("pages", s.manifest.pageCount.toString(), accent)135                    Spacer(Modifier.height(24.dp))136                    CodexButton("Save PDF…", accent, filled = true) { saveLauncher.launch(codexPdfFileName()) }137                    Spacer(Modifier.height(10.dp))138                    CodexButton("Generate again", peachColors.T2, filled = false) { vm.startGenerate(System.currentTimeMillis()) }139                }140141                is GenerateState.Error -> Column {142                    Text("Couldn't generate", color = peachColors.Breach, fontFamily = PeachType.Display, fontStyle = FontStyle.Italic, fontSize = 19.sp)143                    Spacer(Modifier.height(6.dp))144                    Text(s.message, color = peachColors.T2, fontFamily = PeachType.Body, fontSize = 13.sp)145                    Spacer(Modifier.height(20.dp))146                    CodexButton("Try again", accent, filled = true) { vm.startGenerate(System.currentTimeMillis()) }147                }148            }149        }150    }151}152153internal enum class CodexGenerateGate { PRO_REQUIRED, RECOVERY_REQUIRED, READY }154155internal fun codexGenerateGate(isPro: Boolean, recoveryWordsSet: Boolean): CodexGenerateGate = when {156    !isPro -> CodexGenerateGate.PRO_REQUIRED157    !recoveryWordsSet -> CodexGenerateGate.RECOVERY_REQUIRED158    else -> CodexGenerateGate.READY159}160161internal fun codexPdfFileName(date: LocalDate = LocalDate.now()): String =162    "peach-codex-$date.pdf"163164private fun stepLabel(step: CodexStep): String = when (step) {165    CodexStep.COMPRESS -> "compressing vault…"166    CodexStep.ERASURE -> "adding recovery codes…"167    CodexStep.AZTEC -> "drawing Aztec codes…"168    CodexStep.VERIFY -> "verifying it rebuilds…"169    CodexStep.PDF -> "laying out the page…"170}171172@Composable173internal fun CodexHeader(title: String, onBack: () -> Unit) {174    Row(175        modifier = Modifier.fillMaxWidth().padding(top = 12.dp).height(48.dp),176        verticalAlignment = Alignment.CenterVertically,177    ) {178        Text("‹", color = peachColors.T1, fontSize = 26.sp,179            modifier = Modifier.clip(CircleShape).clickable(onClick = onBack).padding(horizontal = 8.dp))180        Spacer(Modifier.size(8.dp))181        Text(title, color = peachColors.T0, fontFamily = PeachType.Display, fontStyle = FontStyle.Italic, fontSize = 19.sp)182    }183}184185@Composable186private fun SummaryRow(label: String, value: String, accent: Color) {187    Row(Modifier.fillMaxWidth().padding(vertical = 7.dp), horizontalArrangement = Arrangement.SpaceBetween) {188        Text(label, color = peachColors.T2, fontFamily = PeachType.Mono, fontSize = 10.sp)189        Text(value, color = accent, fontFamily = PeachType.Mono, fontSize = 12.sp)190    }191}192193@Composable194private fun GateField(label: String, value: String, accent: Color, onChange: (String) -> Unit) {195    Column(Modifier.fillMaxWidth().padding(vertical = 6.dp)) {196        Text(label, color = peachColors.T2, fontFamily = PeachType.Mono, fontSize = 10.sp)197        BasicTextField(198            value = value,199            onValueChange = { onChange(it) },200            singleLine = true,201            textStyle = PeachType.bodyMono.copy(color = peachColors.T0),202            cursorBrush = SolidColor(accent),203            modifier = Modifier.fillMaxWidth().padding(top = 4.dp),204        )205        Box(Modifier.fillMaxWidth().padding(top = 4.dp).height(1.dp).background(peachColors.T3))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}