VaultHealthScreen.kt

11.8 KB · raw

1package com.peachpasswords.ui.vault23import androidx.compose.foundation.Canvas4import androidx.compose.foundation.border5import androidx.compose.foundation.clickable6import androidx.compose.foundation.layout.Box7import androidx.compose.foundation.layout.Column8import androidx.compose.foundation.layout.Row9import androidx.compose.foundation.layout.Spacer10import androidx.compose.foundation.layout.fillMaxSize11import androidx.compose.foundation.layout.fillMaxWidth12import androidx.compose.foundation.layout.padding13import androidx.compose.foundation.layout.size14import androidx.compose.foundation.layout.systemBarsPadding15import androidx.compose.foundation.layout.width16import androidx.compose.foundation.lazy.LazyColumn17import androidx.compose.foundation.lazy.items18import androidx.compose.foundation.shape.RoundedCornerShape19import androidx.compose.material.icons.Icons20import androidx.compose.material.icons.automirrored.rounded.ArrowBack21import androidx.compose.material3.Icon22import androidx.compose.material3.Text23import androidx.compose.runtime.Composable24import androidx.compose.runtime.collectAsState25import androidx.compose.runtime.getValue26import androidx.compose.runtime.mutableStateMapOf27import androidx.compose.runtime.remember28import androidx.compose.ui.Alignment29import androidx.compose.ui.Modifier30import androidx.compose.ui.draw.clip31import androidx.compose.ui.geometry.Offset32import androidx.compose.ui.geometry.Size33import androidx.compose.ui.graphics.Color34import androidx.compose.ui.graphics.StrokeCap35import androidx.compose.ui.graphics.drawscope.Stroke36import androidx.compose.ui.text.TextStyle37import androidx.compose.ui.text.font.FontStyle38import androidx.compose.ui.text.style.TextAlign39import androidx.compose.ui.unit.dp40import androidx.compose.ui.unit.sp41import androidx.hilt.navigation.compose.hiltViewModel42import androidx.lifecycle.ViewModel43import com.peachpasswords.ui.components.FaviconInitials44import com.peachpasswords.ui.components.PeachMark45import com.peachpasswords.ui.theme.LocalPeachAccent46import com.peachpasswords.ui.theme.peachColors47import com.peachpasswords.ui.theme.PeachType48import com.peachpasswords.ui.theme.ShellBackground49import com.peachpasswords.vault.VaultEntry50import com.peachpasswords.vault.VaultHealth51import com.peachpasswords.vault.VaultRepository52import com.peachpasswords.vault.domainKey53import com.peachpasswords.vault.hasPasskey54import dagger.hilt.android.lifecycle.HiltViewModel55import kotlinx.coroutines.flow.MutableStateFlow56import kotlinx.coroutines.flow.StateFlow57import kotlinx.coroutines.flow.asStateFlow58import javax.inject.Inject5960/** Warm amber used for the "reused" and "weak" advisory buckets. */61private val Amber = Color(0xFFC8A840)6263/** Local password-age threshold for the "Old" bucket: 365 days. */64private const val OLD_PASSWORD_MS = 365L * 24 * 60 * 60 * 10006566@HiltViewModel67class VaultHealthViewModel @Inject constructor(68    private val vault: VaultRepository,69) : ViewModel() {70    val entries: StateFlow<List<VaultEntry>> = vault.entries7172    private val _report = MutableStateFlow(VaultHealth.buildReport(vault.getEntries()))73    val report: StateFlow<VaultHealth.VaultHealthReport> = _report.asStateFlow()7475    /** Re-read the live entry list and recompute the local health report. */76    fun recompute() {77        _report.value = VaultHealth.buildReport(vault.getEntries())78    }79}8081@Composable82fun VaultHealthScreen(83    onBack: () -> Unit,84    onEditEntry: (String) -> Unit,85    vm: VaultHealthViewModel = hiltViewModel(),86) {87    val accent = LocalPeachAccent.current88    val entries by vm.entries.collectAsState()89    val report by vm.report.collectAsState()9091    val byId = remember(entries) { entries.associateBy { it.id } }92    val flags = remember(report) { report.entries.associateBy { it.entryId } }9394    val now = System.currentTimeMillis()95    val breached = report.entries.filter { it.isBreach }.mapNotNull { byId[it.entryId] }96    val reused = report.entries.filter { it.isReused }.mapNotNull { byId[it.entryId] }97    val weak = report.entries.filter { it.isWeak }.mapNotNull { byId[it.entryId] }98    val old = entries.filter { e ->99        e.passwordChangedAt?.let { (now - it) > OLD_PASSWORD_MS } == true100    }101    val noPasskey = entries.filter { !it.hasPasskey() }102103    val allHealthy = breached.isEmpty() && reused.isEmpty() &&104        weak.isEmpty() && old.isEmpty() && noPasskey.isEmpty()105106    val expanded = remember { mutableStateMapOf<String, Boolean>() }107108    ShellBackground {109        Column(110            modifier = Modifier111                .fillMaxSize()112                .systemBarsPadding()113                .padding(horizontal = 20.dp, vertical = 16.dp),114        ) {115            // Top bar: back · "vault health" · "check now"116            Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth()) {117                Icon(118                    Icons.AutoMirrored.Rounded.ArrowBack,119                    contentDescription = "back",120                    tint = peachColors.T0,121                    modifier = Modifier.size(22.dp).clip(RoundedCornerShape(8.dp)).clickable(onClick = onBack),122                )123                Spacer(Modifier.width(14.dp))124                Text(125                    "vault health",126                    color = peachColors.T0,127                    style = TextStyle(128                        fontFamily = PeachType.Display, fontStyle = FontStyle.Italic, fontSize = 22.sp,129                    ),130                )131                Spacer(Modifier.weight(1f))132                GhostPill("check now", accent) { vm.recompute() }133            }134135            Spacer(Modifier.size(22.dp))136137            ScoreRing(score = report.vaultScore, accent = accent)138139            Spacer(Modifier.size(24.dp))140141            if (allHealthy) {142                Column(143                    modifier = Modifier.fillMaxSize(),144                    horizontalAlignment = Alignment.CenterHorizontally,145                ) {146                    Spacer(Modifier.weight(1f))147                    PeachMark(size = 60.dp)148                    Spacer(Modifier.size(18.dp))149                    Text(150                        "Your vault looks healthy.",151                        color = peachColors.T1,152                        textAlign = TextAlign.Center,153                        style = TextStyle(154                            fontFamily = PeachType.Display, fontStyle = FontStyle.Italic, fontSize = 20.sp,155                        ),156                    )157                    Spacer(Modifier.weight(1f))158                }159            } else {160                val breachColor = peachColors.Breach161                val dimColor = peachColors.T2162                LazyColumn(modifier = Modifier.weight(1f)) {163                    healthSection("Breached", breached, breachColor, expanded, onEditEntry)164                    healthSection("Reused", reused, Amber, expanded, onEditEntry)165                    healthSection("Weak", weak, Amber, expanded, onEditEntry)166                    healthSection("Old", old, dimColor, expanded, onEditEntry)167                    healthSection("No passkey", noPasskey, dimColor, expanded, onEditEntry)168                }169            }170        }171    }172}173174private fun androidx.compose.foundation.lazy.LazyListScope.healthSection(175    title: String,176    bucket: List<VaultEntry>,177    badgeColor: Color,178    expanded: androidx.compose.runtime.snapshots.SnapshotStateMap<String, Boolean>,179    onEditEntry: (String) -> Unit,180) {181    item(key = "header_$title") {182        SectionHeader(183            title = title,184            count = bucket.size,185            badgeColor = badgeColor,186            expanded = expanded[title] == true,187            onToggle = { expanded[title] = !(expanded[title] == true) },188        )189    }190    if (expanded[title] == true) {191        items(bucket, key = { "${title}_${it.id}" }) { entry ->192            HealthEntryRow(entry = entry, onChange = { onEditEntry(entry.id) })193        }194    }195}196197@Composable198private fun SectionHeader(199    title: String,200    count: Int,201    badgeColor: Color,202    expanded: Boolean,203    onToggle: () -> Unit,204) {205    Row(206        verticalAlignment = Alignment.CenterVertically,207        modifier = Modifier208            .fillMaxWidth()209            .clip(RoundedCornerShape(10.dp))210            .clickable(onClick = onToggle)211            .padding(vertical = 12.dp),212    ) {213        Text(214            title,215            color = if (expanded) peachColors.T0 else peachColors.T1,216            style = TextStyle(217                fontFamily = PeachType.Display, fontStyle = FontStyle.Italic, fontSize = 18.sp,218            ),219        )220        Spacer(Modifier.width(10.dp))221        CountBadge(count, badgeColor)222    }223}224225@Composable226private fun HealthEntryRow(entry: VaultEntry, onChange: () -> Unit) {227    Row(228        verticalAlignment = Alignment.CenterVertically,229        modifier = Modifier.fillMaxWidth().padding(start = 6.dp, top = 8.dp, bottom = 8.dp),230    ) {231        FaviconInitials(source = entry.url ?: entry.title, size = 26.dp)232        Spacer(Modifier.width(12.dp))233        Column(Modifier.weight(1f)) {234            Text(entry.title, color = peachColors.T0, style = PeachType.entryNameList)235            val domain = entry.domainKey()236            if (!domain.isNullOrBlank()) {237                Text(domain, color = peachColors.T2, style = PeachType.tinyMono)238            }239        }240        GhostPill("Change password", LocalPeachAccent.current, onChange)241    }242}243244@Composable245private fun ScoreRing(score: Int, accent: Color) {246    val track = peachColors.T3247    Box(Modifier.fillMaxWidth(), contentAlignment = Alignment.Center) {248        Box(contentAlignment = Alignment.Center) {249            Canvas(modifier = Modifier.size(100.dp)) {250                val stroke = 8.dp.toPx()251                val inset = stroke / 2f252                val arcSize = Size(size.width - stroke, size.height - stroke)253                val topLeft = Offset(inset, inset)254                drawArc(255                    color = track,256                    startAngle = -90f,257                    sweepAngle = 360f,258                    useCenter = false,259                    topLeft = topLeft,260                    size = arcSize,261                    style = Stroke(width = stroke, cap = StrokeCap.Round),262                )263                drawArc(264                    color = accent,265                    startAngle = -90f,266                    sweepAngle = (score.coerceIn(0, 100) / 100f) * 360f,267                    useCenter = false,268                    topLeft = topLeft,269                    size = arcSize,270                    style = Stroke(width = stroke, cap = StrokeCap.Round),271                )272            }273            Column(horizontalAlignment = Alignment.CenterHorizontally) {274                Text(275                    score.toString(),276                    color = peachColors.T0,277                    style = TextStyle(278                        fontFamily = PeachType.Display, fontStyle = FontStyle.Italic, fontSize = 44.sp,279                    ),280                )281                Text(282                    "vault score",283                    color = peachColors.T2,284                    style = PeachType.labelMono.copy(fontSize = 9.sp),285                )286            }287        }288    }289}290291@Composable292private fun CountBadge(count: Int, color: Color) {293    Box(294        modifier = Modifier295            .clip(RoundedCornerShape(999.dp))296            .border(1.dp, color.copy(alpha = 0.4f), RoundedCornerShape(999.dp))297            .padding(horizontal = 9.dp, vertical = 3.dp),298    ) {299        Text(count.toString(), color = color, style = PeachType.sortPill.copy(fontSize = 10.sp))300    }301}302303@Composable304private fun GhostPill(label: String, color: Color, onClick: () -> Unit) {305    Box(306        modifier = Modifier307            .clip(RoundedCornerShape(999.dp))308            .border(1.dp, color.copy(alpha = 0.4f), RoundedCornerShape(999.dp))309            .clickable(onClick = onClick)310            .padding(horizontal = 12.dp, vertical = 5.dp),311    ) {312        Text(label, color = color, style = PeachType.sortPill.copy(fontSize = 10.sp))313    }314}