PanicScreen.kt

11.9 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.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.text.TextStyle32import androidx.compose.ui.text.font.FontStyle33import androidx.compose.ui.unit.sp34import androidx.compose.ui.unit.dp35import androidx.hilt.navigation.compose.hiltViewModel36import androidx.lifecycle.ViewModel37import androidx.lifecycle.viewModelScope38import com.peachpasswords.ui.components.RuneGridBackground39import com.peachpasswords.ui.components.PeachTopBar40import com.peachpasswords.ui.theme.LocalPeachAccent41import com.peachpasswords.ui.theme.LocalPeachColors42import com.peachpasswords.ui.theme.PeachSpacing43import com.peachpasswords.ui.theme.peachColors44import com.peachpasswords.ui.theme.peachDark45import com.peachpasswords.ui.theme.PeachType46import android.content.Context47import dagger.hilt.android.qualifiers.ApplicationContext48import com.peachpasswords.ui.onboarding.ONBOARD_KEY49import com.peachpasswords.ui.onboarding.ONBOARD_PREFS50import com.peachpasswords.sync.SyncFreshStartResetter51import com.peachpasswords.vault.VaultRepository52import dagger.hilt.android.lifecycle.HiltViewModel53import kotlinx.coroutines.launch54import javax.inject.Inject5556@HiltViewModel57class PanicViewModel @Inject constructor(58    private val vault: VaultRepository,59    private val syncFreshStart: SyncFreshStartResetter,60    @ApplicationContext private val appContext: Context,61) : ViewModel() {62    val entryCount: Int get() = vault.getEntries().size6364    fun destroy(onDone: () -> Unit) = viewModelScope.launch {65        vault.destroyVault()66        syncFreshStart.reset()67        // Destroying the vault puts the app back to factory state, so clear the68        // "already onboarded" flag too — otherwise the next launch skipped the69        // intro and dropped straight onto create-vault, which is not what a70        // fresh start should look like.71        appContext.getSharedPreferences(ONBOARD_PREFS, Context.MODE_PRIVATE)72            .edit().remove(ONBOARD_KEY).apply()73        onDone()74    }75}7677@Composable78fun PanicScreen(79    onBack: () -> Unit,80    onDestroyed: () -> Unit,81    vm: PanicViewModel = hiltViewModel(),82) {83    var step by remember { mutableStateOf(1) }84    val entryCount = remember { vm.entryCount }8586    // Panic always renders dark regardless of app theme: its tokens must stay87    // readable on the intentional near-black field below (keep the user's accent).88    val accent = LocalPeachAccent.current89    val darkPalette = remember(accent) { peachDark(accent) }9091    CompositionLocalProvider(LocalPeachColors provides darkPalette) {92    // The panic flow is intentionally cold: no bloom, a flat near-black field so93    // it reads as a dead-end rather than another lit-up screen in the app.94    Box(modifier = Modifier.fillMaxSize().background(Color(0xFF0C0A06))) {95        // Browser parity: the final step runs a drifting rune field with runes96        // flaring and dying behind it. The earlier steps stay deliberately cold97        // and flat — the chaos is what marks this as the point of no return.98        if (step == 3) {99            RuneGridBackground(100                modifier = Modifier.fillMaxSize(),101                alpha = 0.22f,102                glowing = true,103            )104        }105        Column(106            modifier = Modifier107                .fillMaxSize()108                .systemBarsPadding()109                .padding(horizontal = PeachSpacing.lg, vertical = PeachSpacing.md),110        ) {111            PeachTopBar(112                title = "Panic",113                leading = {114                    Icon(115                        Icons.AutoMirrored.Rounded.ArrowBack,116                        contentDescription = "back",117                        tint = peachColors.T0,118                        modifier = Modifier119                            .size(22.dp)120                            .clip(RoundedCornerShape(PeachSpacing.sm))121                            .clickable(onClick = onBack),122                    )123                },124            )125            Box(126                modifier = Modifier.fillMaxSize().padding(horizontal = PeachSpacing.sm),127                contentAlignment = Alignment.Center,128            ) {129                Column(verticalArrangement = Arrangement.Center) {130                    when (step) {131                        1 -> Step1(onCancel = onBack, onContinue = { step = 2 })132                        2 -> Step2(entryCount = entryCount, onBack = { step = 1 }, onContinue = { step = 3 })133                        else -> Step3(onBack = { step = 2 }, onDestroy = { vm.destroy(onDestroyed) })134                    }135                }136            }137        }138    }139    }140}141142@Composable143private fun Step1(onCancel: () -> Unit, onContinue: () -> Unit) {144    Headline("destroy vault", 44.sp, peachColors.Breach)145    Spacer(Modifier.height(PeachSpacing.lg))146    Text(147        "This will permanently delete your vault and all saved passwords from this device. This cannot be undone.",148        color = peachColors.T1,149        style = PeachType.body,150    )151    Spacer(Modifier.height(PeachSpacing.xl))152    Column(153        modifier = Modifier.fillMaxWidth(),154        verticalArrangement = Arrangement.spacedBy(PeachSpacing.sm),155    ) {156        GhostButton("Cancel", peachColors.T1, onCancel)157        DangerButton("I understand, continue", enabled = true, onClick = onContinue)158    }159}160161@Composable162private fun Step2(entryCount: Int, onBack: () -> Unit, onContinue: () -> Unit) {163    Headline("are you sure?", 32.sp, peachColors.Breach)164    Spacer(Modifier.height(PeachSpacing.lg))165    Text(166        "You will lose access to all $entryCount saved passwords.",167        color = peachColors.T1,168        style = PeachType.body,169    )170    Spacer(Modifier.height(PeachSpacing.xl))171    Column(172        modifier = Modifier.fillMaxWidth(),173        verticalArrangement = Arrangement.spacedBy(PeachSpacing.sm),174    ) {175        GhostButton("Go back", peachColors.T1, onBack)176        DangerButton("Yes, destroy it", enabled = true, onClick = onContinue)177    }178}179180@Composable181private fun Step3(onBack: () -> Unit, onDestroy: () -> Unit) {182    // Chaos: the final step is designed to look like a point of no return.183    // Bright red header, three single-sentence warnings, one solid red button.184    // The user has already confirmed at Step1 and Step2; this screen is185    // deliberately stark rather than carrying a third confirmation gate, so186    // the only escape is the "No — take me back" line under the button.187    Column(modifier = Modifier.fillMaxSize()) {188        // Red header bar — the visual claim that this screen is different.189        Box(190            modifier = Modifier191                .fillMaxWidth()192                .background(peachColors.Breach)193                .padding(vertical = 12.dp, horizontal = PeachSpacing.lg),194            contentAlignment = Alignment.Center,195        ) {196            Text(197                "THIS IS YOUR LAST CHANCE",198                color = peachColors.ShellTopSolid,199                style = PeachType.sortPill.copy(200                    fontSize = 12.sp,201                    fontStyle = FontStyle.Italic,202                ),203            )204        }205        Spacer(Modifier.height(PeachSpacing.lg))206        Text(207            "Your vault is about to be permanently destroyed.",208            color = peachColors.T0,209            style = PeachType.body,210        )211        Spacer(Modifier.height(PeachSpacing.lg))212        Text("There is no recovery.", color = peachColors.Breach, style = PeachType.body)213        Text("There is no support.", color = peachColors.Breach, style = PeachType.body)214        Text("There is no undo.", color = peachColors.Breach, style = PeachType.body)215        Spacer(Modifier.height(PeachSpacing.lg))216        // Hairline divider, matches the body-copy t3 token used elsewhere.217        Box(218            modifier = Modifier219                .fillMaxWidth()220                .height(1.dp)221                .background(peachColors.T3.copy(alpha = 0.25f)),222        )223        Spacer(Modifier.weight(1f))224        ChaosDestroyButton("DESTROY MY VAULT FOREVER", onClick = onDestroy)225        Spacer(Modifier.height(PeachSpacing.sm))226        Box(227            modifier = Modifier.fillMaxWidth(),228            contentAlignment = Alignment.Center,229        ) {230            Text(231                "No — take me back",232                color = peachColors.T2,233                style = PeachType.sortPill.copy(fontSize = 11.sp),234                modifier = Modifier235                    .clip(RoundedCornerShape(999.dp))236                    .clickable(onClick = onBack)237                    .padding(horizontal = PeachSpacing.lg, vertical = 8.dp),238            )239        }240    }241}242243@Composable244private fun ChaosDestroyButton(label: String, onClick: () -> Unit) {245    Box(246        modifier = Modifier247            .fillMaxWidth()248            .clip(RoundedCornerShape(10.dp))249            .background(peachColors.Breach)250            .clickable(onClick = onClick)251            .padding(vertical = 14.dp),252        contentAlignment = Alignment.Center,253    ) {254        Text(255            label,256            color = peachColors.ShellTopSolid,257            style = PeachType.sortPill.copy(258                fontSize = 12.sp,259                fontStyle = FontStyle.Italic,260            ),261        )262    }263}264265@Composable266private fun Headline(text: String, fontSize: androidx.compose.ui.unit.TextUnit, color: Color) {267    Text(268        text,269        color = color,270        style = TextStyle(271            fontFamily = PeachType.Display,272            fontStyle = FontStyle.Italic,273            fontSize = fontSize,274        ),275    )276}277278@Composable279private fun GhostButton(label: String, color: Color, onClick: () -> Unit) {280    Box(281        modifier = Modifier282            .fillMaxWidth()283            .clip(RoundedCornerShape(999.dp))284            .border(1.dp, color.copy(alpha = 0.4f), RoundedCornerShape(999.dp))285            .clickable(onClick = onClick)286            .padding(horizontal = PeachSpacing.lg, vertical = 11.dp),287        contentAlignment = Alignment.Center,288    ) {289        Text(label, color = color, style = PeachType.sortPill.copy(fontSize = 11.sp))290    }291}292293@Composable294private fun DangerButton(label: String, enabled: Boolean, onClick: () -> Unit) {295    val alpha = if (enabled) 1f else 0.4f296    Box(297        modifier = Modifier298            .fillMaxWidth()299            .clip(RoundedCornerShape(999.dp))300            .background(peachColors.BreachBg.copy(alpha = peachColors.BreachBg.alpha * alpha))301            .border(1.dp, peachColors.Breach.copy(alpha = 0.55f * alpha), RoundedCornerShape(999.dp))302            .clickable(enabled = enabled, onClick = onClick)303            .padding(horizontal = PeachSpacing.lg, vertical = 11.dp),304        contentAlignment = Alignment.Center,305    ) {306        Text(307            label,308            color = peachColors.Breach.copy(alpha = alpha),309            style = PeachType.sortPill.copy(fontSize = 11.sp),310        )311    }312}