PanicScreen.kt

9.3 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.PeachField39import 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().size6465    fun destroy(onDone: () -> Unit) = viewModelScope.launch {66        vault.destroyVault()67        syncFreshStart.reset()68        // Destroying the vault puts the app back to factory state, so clear the69        // "already onboarded" flag too — otherwise the next launch skipped the70        // intro and dropped straight onto create-vault, which is not what a71        // fresh start should look like.72        appContext.getSharedPreferences(ONBOARD_PREFS, Context.MODE_PRIVATE)73            .edit().remove(ONBOARD_KEY).apply()74        onDone()75    }76}7778@Composable79fun PanicScreen(80    onBack: () -> Unit,81    onDestroyed: () -> Unit,82    vm: PanicViewModel = hiltViewModel(),83) {84    var step by remember { mutableStateOf(1) }85    val entryCount = remember { vm.entryCount }8687    // Panic always renders dark regardless of app theme: its tokens must stay88    // readable on the intentional near-black field below (keep the user's accent).89    val accent = LocalPeachAccent.current90    val darkPalette = remember(accent) { peachDark(accent) }9192    CompositionLocalProvider(LocalPeachColors provides darkPalette) {93    // The panic flow is intentionally cold: no bloom, a flat near-black field so94    // it reads as a dead-end rather than another lit-up screen in the app.95    Box(modifier = Modifier.fillMaxSize().background(Color(0xFF0C0A06))) {96        // Browser parity: the final step runs a drifting rune field with runes97        // flaring and dying behind it. The earlier steps stay deliberately cold98        // and flat — the chaos is what marks this as the point of no return.99        if (step == 3) {100            RuneGridBackground(101                modifier = Modifier.fillMaxSize(),102                alpha = 0.22f,103                glowing = true,104            )105        }106        Column(107            modifier = Modifier108                .fillMaxSize()109                .systemBarsPadding()110                .padding(horizontal = PeachSpacing.lg, vertical = PeachSpacing.md),111        ) {112            PeachTopBar(113                title = "Panic",114                leading = {115                    Icon(116                        Icons.AutoMirrored.Rounded.ArrowBack,117                        contentDescription = "back",118                        tint = peachColors.T0,119                        modifier = Modifier120                            .size(22.dp)121                            .clip(RoundedCornerShape(PeachSpacing.sm))122                            .clickable(onClick = onBack),123                    )124                },125            )126            Box(127                modifier = Modifier.fillMaxSize().padding(horizontal = PeachSpacing.sm),128                contentAlignment = Alignment.Center,129            ) {130                Column(verticalArrangement = Arrangement.Center) {131                    when (step) {132                        1 -> Step1(onCancel = onBack, onContinue = { step = 2 })133                        2 -> Step2(entryCount = entryCount, onBack = { step = 1 }, onContinue = { step = 3 })134                        else -> Step3(onDestroy = { vm.destroy(onDestroyed) })135                    }136                }137            }138        }139    }140    }141}142143@Composable144private fun Step1(onCancel: () -> Unit, onContinue: () -> Unit) {145    Headline("destroy vault", 44.sp, peachColors.Breach)146    Spacer(Modifier.height(PeachSpacing.lg))147    Text(148        "This will permanently delete your vault and all saved passwords from this device. This cannot be undone.",149        color = peachColors.T1,150        style = PeachType.body,151    )152    Spacer(Modifier.height(PeachSpacing.xl))153    Column(154        modifier = Modifier.fillMaxWidth(),155        verticalArrangement = Arrangement.spacedBy(PeachSpacing.sm),156    ) {157        GhostButton("Cancel", peachColors.T1, onCancel)158        DangerButton("I understand, continue", enabled = true, onClick = onContinue)159    }160}161162@Composable163private fun Step2(entryCount: Int, onBack: () -> Unit, onContinue: () -> Unit) {164    Headline("are you sure?", 32.sp, peachColors.Breach)165    Spacer(Modifier.height(PeachSpacing.lg))166    Text(167        "You will lose access to all $entryCount saved passwords.",168        color = peachColors.T1,169        style = PeachType.body,170    )171    Spacer(Modifier.height(PeachSpacing.xl))172    Column(173        modifier = Modifier.fillMaxWidth(),174        verticalArrangement = Arrangement.spacedBy(PeachSpacing.sm),175    ) {176        GhostButton("Go back", peachColors.T1, onBack)177        DangerButton("Yes, destroy it", enabled = true, onClick = onContinue)178    }179}180181@Composable182private fun Step3(onDestroy: () -> Unit) {183    var typed by remember { mutableStateOf("") }184    val confirmed = typed == "DESTROY"185186    Headline("type DESTROY to confirm", 28.sp, peachColors.T0)187    Spacer(Modifier.height(PeachSpacing.lg))188    PeachField(189        label = "confirmation",190        value = typed,191        onValueChange = { typed = it },192    )193    Spacer(Modifier.height(PeachSpacing.xl))194    DangerButton("Destroy vault", enabled = confirmed, onClick = onDestroy)195}196197@Composable198private fun Headline(text: String, fontSize: androidx.compose.ui.unit.TextUnit, color: Color) {199    Text(200        text,201        color = color,202        style = TextStyle(203            fontFamily = PeachType.Display,204            fontStyle = FontStyle.Italic,205            fontSize = fontSize,206        ),207    )208}209210@Composable211private fun GhostButton(label: String, color: Color, onClick: () -> Unit) {212    Box(213        modifier = Modifier214            .fillMaxWidth()215            .clip(RoundedCornerShape(999.dp))216            .border(1.dp, color.copy(alpha = 0.4f), RoundedCornerShape(999.dp))217            .clickable(onClick = onClick)218            .padding(horizontal = PeachSpacing.lg, vertical = 11.dp),219        contentAlignment = Alignment.Center,220    ) {221        Text(label, color = color, style = PeachType.sortPill.copy(fontSize = 11.sp))222    }223}224225@Composable226private fun DangerButton(label: String, enabled: Boolean, onClick: () -> Unit) {227    val alpha = if (enabled) 1f else 0.4f228    Box(229        modifier = Modifier230            .fillMaxWidth()231            .clip(RoundedCornerShape(999.dp))232            .background(peachColors.BreachBg.copy(alpha = peachColors.BreachBg.alpha * alpha))233            .border(1.dp, peachColors.Breach.copy(alpha = 0.55f * alpha), RoundedCornerShape(999.dp))234            .clickable(enabled = enabled, onClick = onClick)235            .padding(horizontal = PeachSpacing.lg, vertical = 11.dp),236        contentAlignment = Alignment.Center,237    ) {238        Text(239            label,240            color = peachColors.Breach.copy(alpha = alpha),241            style = PeachType.sortPill.copy(fontSize = 11.sp),242        )243    }244}