CodexGenerateScreen.kt

10.6 KB · raw

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() // MN-3: never screenshot the codex generation / verify code6364    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}