PanicScreen.kt

12.2 KB · raw

1package com.peachpasswords.ui.settings23import androidx.compose.foundation.background4import androidx.compose.foundation.border5import androidx.compose.foundation.clickable6import androidx.compose.foundation.layout.Arrangement7import androidx.compose.foundation.layout.Box8import androidx.compose.foundation.layout.Column9import androidx.compose.foundation.layout.Spacer10import androidx.compose.foundation.layout.fillMaxSize11import androidx.compose.foundation.layout.fillMaxWidth12import androidx.compose.foundation.layout.height13import androidx.compose.foundation.layout.padding14import androidx.compose.foundation.layout.size15import androidx.compose.foundation.layout.systemBarsPadding16import androidx.compose.foundation.shape.RoundedCornerShape17import androidx.compose.material.icons.Icons18import androidx.compose.material.icons.automirrored.rounded.ArrowBack19import androidx.compose.material3.Icon20import androidx.compose.material3.Text21import androidx.compose.runtime.Composable22import androidx.compose.runtime.CompositionLocalProvider23import androidx.compose.runtime.collectAsState24import 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.text.TextStyle33import androidx.compose.ui.text.font.FontStyle34import androidx.compose.ui.unit.sp35import androidx.compose.ui.unit.dp36import androidx.hilt.navigation.compose.hiltViewModel37import androidx.lifecycle.ViewModel38import androidx.lifecycle.viewModelScope39import com.peachpasswords.ui.components.RuneGridBackground40import com.peachpasswords.ui.components.PeachTopBar41import com.peachpasswords.ui.theme.LocalPeachAccent42import com.peachpasswords.ui.theme.LocalPeachColors43import com.peachpasswords.ui.theme.PeachSpacing44import com.peachpasswords.ui.theme.peachColors45import com.peachpasswords.ui.theme.peachDark46import com.peachpasswords.ui.theme.PeachType47import android.content.Context48import dagger.hilt.android.qualifiers.ApplicationContext49import com.peachpasswords.ui.onboarding.ONBOARD_KEY50import com.peachpasswords.ui.onboarding.ONBOARD_PREFS51import com.peachpasswords.sync.SyncFreshStartResetter52import com.peachpasswords.vault.VaultRepository53import dagger.hilt.android.lifecycle.HiltViewModel54import kotlinx.coroutines.launch55import javax.inject.Inject5657@HiltViewModel58class PanicViewModel @Inject constructor(59    private val vault: VaultRepository,60    private val syncFreshStart: SyncFreshStartResetter,61    @ApplicationContext private val appContext: Context,62) : ViewModel() {63    val entryCount: Int get() = vault.getEntries().size64    private val _destroyFailed = kotlinx.coroutines.flow.MutableStateFlow(false)65    val destroyFailed = _destroyFailed6667    fun destroy(onDone: () -> Unit) = viewModelScope.launch {68        if (!vault.destroyVault()) {69            _destroyFailed.value = true70            return@launch71        }72        syncFreshStart.reset()73        appContext.getSharedPreferences(ONBOARD_PREFS, Context.MODE_PRIVATE)74            .edit().remove(ONBOARD_KEY).apply()75        onDone()76    }77}7879@Composable80fun PanicScreen(81    onBack: () -> Unit,82    onDestroyed: () -> Unit,83    vm: PanicViewModel = hiltViewModel(),84) {85    val destroyFailed by vm.destroyFailed.collectAsState()86    var step by remember { mutableStateOf(1) }87    val entryCount = remember { vm.entryCount }88    // Panic always renders dark regardless of app theme: its tokens must stay89    // readable on the intentional near-black field below (keep the user's accent).90    val accent = LocalPeachAccent.current91    val darkPalette = remember(accent) { peachDark(accent) }9293    CompositionLocalProvider(LocalPeachColors provides darkPalette) {94    // The panic flow is intentionally cold: no bloom, a flat near-black field so95    // it reads as a dead-end rather than another lit-up screen in the app.96    Box(modifier = Modifier.fillMaxSize().background(Color(0xFF0C0A06))) {97        // Browser parity: the final step runs a drifting rune field with runes98        // flaring and dying behind it. The earlier steps stay deliberately cold99        // and flat — the chaos is what marks this as the point of no return.100        if (step == 3) {101            RuneGridBackground(102                modifier = Modifier.fillMaxSize(),103                alpha = 0.22f,104                glowing = true,105            )106        }107        Column(108            modifier = Modifier109                .fillMaxSize()110                .systemBarsPadding()111                .padding(horizontal = PeachSpacing.lg, vertical = PeachSpacing.md),112        ) {113            PeachTopBar(114                title = "Panic",115                leading = {116                    Icon(117                        Icons.AutoMirrored.Rounded.ArrowBack,118                        contentDescription = "back",119                        tint = peachColors.T0,120                        modifier = Modifier121                            .size(22.dp)122                            .clip(RoundedCornerShape(PeachSpacing.sm))123                            .clickable(onClick = onBack),124                    )125                },126            )127            Box(128                modifier = Modifier.fillMaxSize().padding(horizontal = PeachSpacing.sm),129                contentAlignment = Alignment.Center,130            ) {131                    when {132                        destroyFailed -> Text(133                            "Peach could not remove every vault artifact. Nothing was marked destroyed; try again.",134                            color = peachColors.Breach,135                            style = PeachType.body,136                        )137                        step == 1 -> Step1(onCancel = onBack, onContinue = { step = 2 })138                        step == 2 -> Step2(entryCount = entryCount, onBack = { step = 1 }, onContinue = { step = 3 })139                        else -> Step3(onBack = { step = 2 }, onDestroy = { vm.destroy(onDestroyed) })140                    }141            }142        }143    }144    }145}146147@Composable148private fun Step1(onCancel: () -> Unit, onContinue: () -> Unit) {149    Headline("destroy vault", 44.sp, peachColors.Breach)150    Spacer(Modifier.height(PeachSpacing.lg))151    Text(152        "This will permanently delete your vault and all saved passwords from this device. This cannot be undone.",153        color = peachColors.T1,154        style = PeachType.body,155    )156    Spacer(Modifier.height(PeachSpacing.xl))157    Column(158        modifier = Modifier.fillMaxWidth(),159        verticalArrangement = Arrangement.spacedBy(PeachSpacing.sm),160    ) {161        GhostButton("Cancel", peachColors.T1, onCancel)162        DangerButton("I understand, continue", enabled = true, onClick = onContinue)163    }164}165166@Composable167private fun Step2(entryCount: Int, onBack: () -> Unit, onContinue: () -> Unit) {168    Headline("are you sure?", 32.sp, peachColors.Breach)169    Spacer(Modifier.height(PeachSpacing.lg))170    Text(171        "You will lose access to all $entryCount saved passwords.",172        color = peachColors.T1,173        style = PeachType.body,174    )175    Spacer(Modifier.height(PeachSpacing.xl))176    Column(177        modifier = Modifier.fillMaxWidth(),178        verticalArrangement = Arrangement.spacedBy(PeachSpacing.sm),179    ) {180        GhostButton("Go back", peachColors.T1, onBack)181        DangerButton("Yes, destroy it", enabled = true, onClick = onContinue)182    }183}184185@Composable186private fun Step3(onBack: () -> Unit, onDestroy: () -> Unit) {187    // Chaos: the final step is designed to look like a point of no return.188    // Bright red header, three single-sentence warnings, one solid red button.189    // The user has already confirmed at Step1 and Step2; this screen is190    // deliberately stark rather than carrying a third confirmation gate, so191    // the only escape is the "No — take me back" line under the button.192    Column(modifier = Modifier.fillMaxSize()) {193        // Red header bar — the visual claim that this screen is different.194        Box(195            modifier = Modifier196                .fillMaxWidth()197                .background(peachColors.Breach)198                .padding(vertical = 12.dp, horizontal = PeachSpacing.lg),199            contentAlignment = Alignment.Center,200        ) {201            Text(202                "THIS IS YOUR LAST CHANCE",203                color = peachColors.ShellTopSolid,204                style = PeachType.sortPill.copy(205                    fontSize = 12.sp,206                    fontStyle = FontStyle.Italic,207                ),208            )209        }210        Spacer(Modifier.height(PeachSpacing.lg))211        Text(212            "Your vault is about to be permanently destroyed.",213            color = peachColors.T0,214            style = PeachType.body,215        )216        Spacer(Modifier.height(PeachSpacing.lg))217        Text("There is no recovery.", color = peachColors.Breach, style = PeachType.body)218        Text("There is no support.", color = peachColors.Breach, style = PeachType.body)219        Text("There is no undo.", color = peachColors.Breach, style = PeachType.body)220        Spacer(Modifier.height(PeachSpacing.lg))221        // Hairline divider, matches the body-copy t3 token used elsewhere.222        Box(223            modifier = Modifier224                .fillMaxWidth()225                .height(1.dp)226                .background(peachColors.T3.copy(alpha = 0.25f)),227        )228        Spacer(Modifier.weight(1f))229        ChaosDestroyButton("DESTROY MY VAULT FOREVER", onClick = onDestroy)230        Spacer(Modifier.height(PeachSpacing.sm))231        Box(232            modifier = Modifier.fillMaxWidth(),233            contentAlignment = Alignment.Center,234        ) {235            Text(236                "No — take me back",237                color = peachColors.T2,238                style = PeachType.sortPill.copy(fontSize = 11.sp),239                modifier = Modifier240                    .clip(RoundedCornerShape(999.dp))241                    .clickable(onClick = onBack)242                    .padding(horizontal = PeachSpacing.lg, vertical = 8.dp),243            )244        }245    }246}247248@Composable249private fun ChaosDestroyButton(label: String, onClick: () -> Unit) {250    Box(251        modifier = Modifier252            .fillMaxWidth()253            .clip(RoundedCornerShape(10.dp))254            .background(peachColors.Breach)255            .clickable(onClick = onClick)256            .padding(vertical = 14.dp),257        contentAlignment = Alignment.Center,258    ) {259        Text(260            label,261            color = peachColors.ShellTopSolid,262            style = PeachType.sortPill.copy(263                fontSize = 12.sp,264                fontStyle = FontStyle.Italic,265            ),266        )267    }268}269270@Composable271private fun Headline(text: String, fontSize: androidx.compose.ui.unit.TextUnit, color: Color) {272    Text(273        text,274        color = color,275        style = TextStyle(276            fontFamily = PeachType.Display,277            fontStyle = FontStyle.Italic,278            fontSize = fontSize,279        ),280    )281}282283@Composable284private fun GhostButton(label: String, color: Color, onClick: () -> Unit) {285    Box(286        modifier = Modifier287            .fillMaxWidth()288            .clip(RoundedCornerShape(999.dp))289            .border(1.dp, color.copy(alpha = 0.4f), RoundedCornerShape(999.dp))290            .clickable(onClick = onClick)291            .padding(horizontal = PeachSpacing.lg, vertical = 11.dp),292        contentAlignment = Alignment.Center,293    ) {294        Text(label, color = color, style = PeachType.sortPill.copy(fontSize = 11.sp))295    }296}297298@Composable299private fun DangerButton(label: String, enabled: Boolean, onClick: () -> Unit) {300    val alpha = if (enabled) 1f else 0.4f301    Box(302        modifier = Modifier303            .fillMaxWidth()304            .clip(RoundedCornerShape(999.dp))305            .background(peachColors.BreachBg.copy(alpha = peachColors.BreachBg.alpha * alpha))306            .border(1.dp, peachColors.Breach.copy(alpha = 0.55f * alpha), RoundedCornerShape(999.dp))307            .clickable(enabled = enabled, onClick = onClick)308            .padding(horizontal = PeachSpacing.lg, vertical = 11.dp),309        contentAlignment = Alignment.Center,310    ) {311        Text(312            label,313            color = peachColors.Breach.copy(alpha = alpha),314            style = PeachType.sortPill.copy(fontSize = 11.sp),315        )316    }317}