CodexGenerateScreen.kt

10.9 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) } }61    val shareLauncher = rememberLauncherForActivityResult(62        ActivityResultContracts.StartActivityForResult(),63    ) { vm.discardSharedPdf() }6465    SecureFlagEffect() // MN-3: never screenshot the codex generation / verify code6667    ShellBackground {68        Column(Modifier.fillMaxSize().systemBarsPadding().padding(horizontal = PeachSpacing.xl)) {69            Spacer(Modifier.height(PeachSpacing.md))70            CodexTopBar("Codex backup", onBack)71            Spacer(Modifier.height(PeachSpacing.sm))72            Text(73                "A printable PDF of Aztec codes that encodes your encrypted vault. Keep it with your recovery words — together they restore everything, even offline.",74                color = peachColors.T2, fontFamily = PeachType.Body, fontSize = 13.sp,75            )76            Spacer(Modifier.height(PeachSpacing.xxl))7778            when (val s = state) {79                is GenerateState.Idle -> {80                    when (codexGenerateGate(vm.isPro, vm.recoveryWordsSet)) {81                        CodexGenerateGate.PRO_REQUIRED -> {82                            PeachEmptyOrEndState(83                                message = "Codex generation is a Pro feature",84                                detail = "Restoring from an existing Codex is always free. Creating a new printable Codex backup requires Peach Pro.",85                                action = {86                                    CodexButton("Open Pro", accent, filled = true, onClick = onUpgradePro)87                                },88                            )89                        }90                        CodexGenerateGate.RECOVERY_REQUIRED -> {91                            PeachEmptyOrEndState(92                                message = "Set up recovery words first",93                                detail = "A Codex backup only restores alongside your recovery words. Set them up before generating one.",94                                action = {95                                    CodexButton("Set up recovery words", accent, filled = true, onClick = onSetupRecovery)96                                },97                            )98                        }99                        CodexGenerateGate.READY -> {100                            var w3 by remember { mutableStateOf("") }101                            var w7 by remember { mutableStateOf("") }102                            var w11 by remember { mutableStateOf("") }103                            val ok = vm.gateMatches(w3, w7, w11)104                            Text("Confirm your recovery words", color = peachColors.T1, fontFamily = PeachType.Display, fontStyle = FontStyle.Italic, fontSize = 19.sp)105                            Spacer(Modifier.height(PeachSpacing.xs))106                            Text("Enter the words at positions 3, 7, and 11.", color = peachColors.T2, fontFamily = PeachType.Body, fontSize = 13.sp)107                            Spacer(Modifier.height(PeachSpacing.lg))108                            PeachField("Word #3", w3, { w3 = it })109                            Spacer(Modifier.height(PeachSpacing.md))110                            PeachField("Word #7", w7, { w7 = it })111                            Spacer(Modifier.height(PeachSpacing.md))112                            PeachField("Word #11", w11, { w11 = it })113                            Spacer(Modifier.height(PeachSpacing.xl))114                            CodexButton(115                                "Generate Codex PDF",116                                if (ok) accent else peachColors.T2,117                                filled = ok,118                            ) { if (ok) vm.startGenerate(System.currentTimeMillis()) }119                        }120                    }121                }122123                is GenerateState.Running -> Column {124                    Text(stepLabel(s.step), color = peachColors.T1, fontFamily = PeachType.Mono, fontSize = 11.sp)125                    Spacer(Modifier.height(PeachSpacing.sm))126                    val frac = if (s.total > 0) s.current.toFloat() / s.total else 0f127                    LinearProgressIndicator(128                        progress = { if (s.step == CodexStep.AZTEC) frac else 0.5f },129                        modifier = Modifier.fillMaxWidth().height(4.dp),130                        color = accent, trackColor = peachColors.S2,131                    )132                    if (s.step == CodexStep.AZTEC) {133                        Spacer(Modifier.height(PeachSpacing.xs))134                        Text("${s.current} / ${s.total} codes", color = peachColors.T3, fontFamily = PeachType.Mono, fontSize = 9.sp)135                    }136                }137138                is GenerateState.Done -> Column {139                    SummaryRow("verify code", s.manifest.verifyCode, accent)140                    SummaryRow("entries", s.manifest.entryCount.toString(), accent)141                    SummaryRow("codes", "${s.manifest.totalChunks} (${s.manifest.parityChunks} recovery)", accent)142                    SummaryRow("pages", s.manifest.pageCount.toString(), accent)143                    Spacer(Modifier.height(PeachSpacing.xl))144                    CodexButton("Save PDF…", accent, filled = true) {145                        vm.expectTransientBackground()146                        saveLauncher.launch(codexPdfFileName())147                    }148                    Spacer(Modifier.height(PeachSpacing.sm))149                    CodexButton("Share PDF…", peachColors.T2, filled = false) {150                        val uri = vm.sharePdfUri() ?: return@CodexButton151                        vm.expectTransientBackground()152                        val intent = Intent(Intent.ACTION_SEND).apply {153                            type = "application/pdf"154                            putExtra(Intent.EXTRA_STREAM, uri)155                            addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)156                        }157                        // Launch for result so the cached artefact can be removed158                        // as soon as the chooser returns (PB-26-137).159                        shareLauncher.launch(Intent.createChooser(intent, "Share Codex PDF"))160                    }161                    Spacer(Modifier.height(PeachSpacing.sm))162                    CodexButton("Generate again", peachColors.T2, filled = false) { vm.startGenerate(System.currentTimeMillis()) }163                }164165                is GenerateState.Error -> PeachEmptyOrEndState(166                    message = "Couldn't generate",167                    detail = s.message,168                    action = {169                        CodexButton("Try again", accent, filled = true) { vm.startGenerate(System.currentTimeMillis()) }170                    },171                )172            }173        }174    }175}176177internal enum class CodexGenerateGate { PRO_REQUIRED, RECOVERY_REQUIRED, READY }178179internal fun codexGenerateGate(isPro: Boolean, recoveryWordsSet: Boolean): CodexGenerateGate = when {180    !isPro -> CodexGenerateGate.PRO_REQUIRED181    !recoveryWordsSet -> CodexGenerateGate.RECOVERY_REQUIRED182    else -> CodexGenerateGate.READY183}184185internal fun codexPdfFileName(date: LocalDate = LocalDate.now()): String =186    "peach-codex-$date.pdf"187188private fun stepLabel(step: CodexStep): String = when (step) {189    CodexStep.COMPRESS -> "compressing vault…"190    CodexStep.ERASURE -> "adding recovery codes…"191    CodexStep.AZTEC -> "drawing Aztec codes…"192    CodexStep.VERIFY -> "verifying it rebuilds…"193    CodexStep.PDF -> "laying out the page…"194}195196@Composable197internal fun CodexTopBar(title: String, onBack: () -> Unit) {198    PeachSectionHeader(section = title, onHome = onBack)199}200201@Composable202private fun SummaryRow(label: String, value: String, accent: Color) {203    Row(Modifier.fillMaxWidth().padding(vertical = PeachSpacing.sm), horizontalArrangement = Arrangement.SpaceBetween) {204        Text(label, color = peachColors.T2, fontFamily = PeachType.Mono, fontSize = 10.sp)205        Text(value, color = accent, fontFamily = PeachType.Mono, fontSize = 12.sp)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}