CodexGenerateScreen.kt

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