PanicScreen.kt

9.1 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.vault.VaultRepository52import dagger.hilt.android.lifecycle.HiltViewModel53import kotlinx.coroutines.launch54import javax.inject.Inject5556@HiltViewModel57class PanicViewModel @Inject constructor(58    private val vault: VaultRepository,59    @ApplicationContext private val appContext: Context,60) : ViewModel() {61    val entryCount: Int get() = vault.getEntries().size6263    fun destroy(onDone: () -> Unit) = viewModelScope.launch {64        vault.destroyVault()65        // Destroying the vault puts the app back to factory state, so clear the66        // "already onboarded" flag too — otherwise the next launch skipped the67        // intro and dropped straight onto create-vault, which is not what a68        // fresh start should look like.69        appContext.getSharedPreferences(ONBOARD_PREFS, Context.MODE_PRIVATE)70            .edit().remove(ONBOARD_KEY).apply()71        onDone()72    }73}7475@Composable76fun PanicScreen(77    onBack: () -> Unit,78    onDestroyed: () -> Unit,79    vm: PanicViewModel = hiltViewModel(),80) {81    var step by remember { mutableStateOf(1) }82    val entryCount = remember { vm.entryCount }8384    // Panic always renders dark regardless of app theme: its tokens must stay85    // readable on the intentional near-black field below (keep the user's accent).86    val accent = LocalPeachAccent.current87    val darkPalette = remember(accent) { peachDark(accent) }8889    CompositionLocalProvider(LocalPeachColors provides darkPalette) {90    // The panic flow is intentionally cold: no bloom, a flat near-black field so91    // it reads as a dead-end rather than another lit-up screen in the app.92    Box(modifier = Modifier.fillMaxSize().background(Color(0xFF0C0A06))) {93        // Browser parity: the final step runs a drifting rune field with runes94        // flaring and dying behind it. The earlier steps stay deliberately cold95        // and flat — the chaos is what marks this as the point of no return.96        if (step == 3) {97            RuneGridBackground(98                modifier = Modifier.fillMaxSize(),99                alpha = 0.22f,100                glowing = true,101            )102        }103        Column(104            modifier = Modifier105                .fillMaxSize()106                .systemBarsPadding()107                .padding(horizontal = PeachSpacing.lg, vertical = PeachSpacing.md),108        ) {109            PeachTopBar(110                title = "Panic",111                leading = {112                    Icon(113                        Icons.AutoMirrored.Rounded.ArrowBack,114                        contentDescription = "back",115                        tint = peachColors.T0,116                        modifier = Modifier117                            .size(22.dp)118                            .clip(RoundedCornerShape(PeachSpacing.sm))119                            .clickable(onClick = onBack),120                    )121                },122            )123            Box(124                modifier = Modifier.fillMaxSize().padding(horizontal = PeachSpacing.sm),125                contentAlignment = Alignment.Center,126            ) {127                Column(verticalArrangement = Arrangement.Center) {128                    when (step) {129                        1 -> Step1(onCancel = onBack, onContinue = { step = 2 })130                        2 -> Step2(entryCount = entryCount, onBack = { step = 1 }, onContinue = { step = 3 })131                        else -> Step3(onDestroy = { vm.destroy(onDestroyed) })132                    }133                }134            }135        }136    }137    }138}139140@Composable141private fun Step1(onCancel: () -> Unit, onContinue: () -> Unit) {142    Headline("destroy vault", 44.sp, peachColors.Breach)143    Spacer(Modifier.height(PeachSpacing.lg))144    Text(145        "This will permanently delete your vault and all saved passwords from this device. This cannot be undone.",146        color = peachColors.T1,147        style = PeachType.body,148    )149    Spacer(Modifier.height(PeachSpacing.xl))150    Column(151        modifier = Modifier.fillMaxWidth(),152        verticalArrangement = Arrangement.spacedBy(PeachSpacing.sm),153    ) {154        GhostButton("Cancel", peachColors.T1, onCancel)155        DangerButton("I understand, continue", enabled = true, onClick = onContinue)156    }157}158159@Composable160private fun Step2(entryCount: Int, onBack: () -> Unit, onContinue: () -> Unit) {161    Headline("are you sure?", 32.sp, peachColors.Breach)162    Spacer(Modifier.height(PeachSpacing.lg))163    Text(164        "You will lose access to all $entryCount saved passwords.",165        color = peachColors.T1,166        style = PeachType.body,167    )168    Spacer(Modifier.height(PeachSpacing.xl))169    Column(170        modifier = Modifier.fillMaxWidth(),171        verticalArrangement = Arrangement.spacedBy(PeachSpacing.sm),172    ) {173        GhostButton("Go back", peachColors.T1, onBack)174        DangerButton("Yes, destroy it", enabled = true, onClick = onContinue)175    }176}177178@Composable179private fun Step3(onDestroy: () -> Unit) {180    var typed by remember { mutableStateOf("") }181    val confirmed = typed == "DESTROY"182183    Headline("type DESTROY to confirm", 28.sp, peachColors.T0)184    Spacer(Modifier.height(PeachSpacing.lg))185    PeachField(186        label = "confirmation",187        value = typed,188        onValueChange = { typed = it },189    )190    Spacer(Modifier.height(PeachSpacing.xl))191    DangerButton("Destroy vault", enabled = confirmed, onClick = onDestroy)192}193194@Composable195private fun Headline(text: String, fontSize: androidx.compose.ui.unit.TextUnit, color: Color) {196    Text(197        text,198        color = color,199        style = TextStyle(200            fontFamily = PeachType.Display,201            fontStyle = FontStyle.Italic,202            fontSize = fontSize,203        ),204    )205}206207@Composable208private fun GhostButton(label: String, color: Color, onClick: () -> Unit) {209    Box(210        modifier = Modifier211            .fillMaxWidth()212            .clip(RoundedCornerShape(999.dp))213            .border(1.dp, color.copy(alpha = 0.4f), RoundedCornerShape(999.dp))214            .clickable(onClick = onClick)215            .padding(horizontal = PeachSpacing.lg, vertical = 11.dp),216        contentAlignment = Alignment.Center,217    ) {218        Text(label, color = color, style = PeachType.sortPill.copy(fontSize = 11.sp))219    }220}221222@Composable223private fun DangerButton(label: String, enabled: Boolean, onClick: () -> Unit) {224    val alpha = if (enabled) 1f else 0.4f225    Box(226        modifier = Modifier227            .fillMaxWidth()228            .clip(RoundedCornerShape(999.dp))229            .background(peachColors.BreachBg.copy(alpha = peachColors.BreachBg.alpha * alpha))230            .border(1.dp, peachColors.Breach.copy(alpha = 0.55f * alpha), RoundedCornerShape(999.dp))231            .clickable(enabled = enabled, onClick = onClick)232            .padding(horizontal = PeachSpacing.lg, vertical = 11.dp),233        contentAlignment = Alignment.Center,234    ) {235        Text(236            label,237            color = peachColors.Breach.copy(alpha = alpha),238            style = PeachType.sortPill.copy(fontSize = 11.sp),239        )240    }241}