VaultHealthScreen.kt

13.1 KB · raw

1package com.peachpasswords.ui.vault23import androidx.compose.foundation.Canvas4import androidx.compose.foundation.border5import androidx.compose.foundation.clickable6import androidx.compose.foundation.layout.Arrangement7import androidx.compose.foundation.layout.Box8import androidx.compose.foundation.layout.BoxWithConstraints9import androidx.compose.foundation.layout.Column10import androidx.compose.foundation.layout.Row11import androidx.compose.foundation.layout.Spacer12import androidx.compose.foundation.layout.fillMaxSize13import androidx.compose.foundation.layout.fillMaxWidth14import androidx.compose.foundation.layout.heightIn15import androidx.compose.foundation.layout.padding16import androidx.compose.foundation.layout.size17import androidx.compose.foundation.layout.systemBarsPadding18import androidx.compose.foundation.layout.width19import androidx.compose.foundation.lazy.LazyColumn20import androidx.compose.foundation.lazy.items21import androidx.compose.foundation.shape.RoundedCornerShape22import androidx.compose.material.icons.Icons23import androidx.compose.material.icons.automirrored.rounded.ArrowBack24import androidx.compose.material3.Icon25import androidx.compose.material3.Text26import androidx.compose.runtime.Composable27import androidx.compose.runtime.collectAsState28import androidx.compose.runtime.getValue29import androidx.compose.runtime.mutableStateMapOf30import androidx.compose.runtime.remember31import androidx.compose.ui.Alignment32import androidx.compose.ui.Modifier33import androidx.compose.ui.draw.clip34import androidx.compose.ui.geometry.Offset35import androidx.compose.ui.geometry.Size36import androidx.compose.ui.graphics.Color37import androidx.compose.ui.graphics.StrokeCap38import androidx.compose.ui.graphics.drawscope.Stroke39import androidx.compose.ui.text.TextStyle40import androidx.compose.ui.text.font.FontStyle41import androidx.compose.ui.text.style.TextAlign42import androidx.compose.ui.unit.dp43import androidx.compose.ui.unit.sp44import androidx.hilt.navigation.compose.hiltViewModel45import androidx.lifecycle.ViewModel46import com.peachpasswords.ui.components.FaviconInitials47import com.peachpasswords.ui.components.PeachEmptyOrEndState48import com.peachpasswords.ui.components.PeachMark49import com.peachpasswords.ui.components.PeachTopBar50import com.peachpasswords.ui.theme.LocalPeachAccent51import com.peachpasswords.ui.theme.PeachSpacing52import com.peachpasswords.ui.theme.peachColors53import com.peachpasswords.ui.theme.PeachType54import com.peachpasswords.ui.theme.ShellBackground55import com.peachpasswords.vault.VaultEntry56import com.peachpasswords.vault.VaultHealth57import com.peachpasswords.vault.VaultRepository58import com.peachpasswords.vault.domainKey59import com.peachpasswords.vault.hasPasskey60import dagger.hilt.android.lifecycle.HiltViewModel61import kotlinx.coroutines.flow.MutableStateFlow62import kotlinx.coroutines.flow.StateFlow63import kotlinx.coroutines.flow.asStateFlow64import javax.inject.Inject6566/** Warm amber used for the "reused" and "weak" advisory buckets. */67private val Amber = Color(0xFFC8A840)6869/** Local password-age threshold for the "Old" bucket: 365 days. */70private const val OLD_PASSWORD_MS = 365L * 24 * 60 * 60 * 10007172/** Approximate on-screen heights used to size the health list's trailing footer. */73private val HEALTH_HEADER_HEIGHT = 52.dp74private val HEALTH_ENTRY_ROW_HEIGHT = 64.dp7576@HiltViewModel77class VaultHealthViewModel @Inject constructor(78    private val vault: VaultRepository,79) : ViewModel() {80    val entries: StateFlow<List<VaultEntry>> = vault.entries8182    private val _report = MutableStateFlow(VaultHealth.buildReport(vault.getEntries()))83    val report: StateFlow<VaultHealth.VaultHealthReport> = _report.asStateFlow()8485    /** Re-read the live entry list and recompute the local health report. */86    fun recompute() {87        _report.value = VaultHealth.buildReport(vault.getEntries())88    }89}9091@Composable92fun VaultHealthScreen(93    onBack: () -> Unit,94    onEditEntry: (String) -> Unit,95    vm: VaultHealthViewModel = hiltViewModel(),96) {97    val accent = LocalPeachAccent.current98    val entries by vm.entries.collectAsState()99    val report by vm.report.collectAsState()100101    val byId = remember(entries) { entries.associateBy { it.id } }102    val flags = remember(report) { report.entries.associateBy { it.entryId } }103104    val now = System.currentTimeMillis()105    val breached = report.entries.filter { it.isBreach }.mapNotNull { byId[it.entryId] }106    val reused = report.entries.filter { it.isReused }.mapNotNull { byId[it.entryId] }107    val weak = report.entries.filter { it.isWeak }.mapNotNull { byId[it.entryId] }108    val old = entries.filter { e ->109        e.passwordChangedAt?.let { (now - it) > OLD_PASSWORD_MS } == true110    }111    val noPasskey = entries.filter { !it.hasPasskey() }112113    val allHealthy = breached.isEmpty() && reused.isEmpty() &&114        weak.isEmpty() && old.isEmpty() && noPasskey.isEmpty()115116    val expanded = remember { mutableStateMapOf<String, Boolean>() }117118    ShellBackground {119        Column(120            modifier = Modifier121                .fillMaxSize()122                .systemBarsPadding()123                .padding(horizontal = PeachSpacing.lg, vertical = PeachSpacing.md),124        ) {125            PeachTopBar(126                title = "vault health",127                leading = {128                    Icon(129                        Icons.AutoMirrored.Rounded.ArrowBack,130                        contentDescription = "back",131                        tint = peachColors.T0,132                        modifier = Modifier.size(22.dp).clip(RoundedCornerShape(PeachSpacing.sm)).clickable(onClick = onBack),133                    )134                },135                actions = {136                    GhostPill("check now", accent) { vm.recompute() }137                },138            )139140            Spacer(Modifier.size(PeachSpacing.lg))141142            ScoreRing(score = report.vaultScore, accent = accent)143144            Spacer(Modifier.size(PeachSpacing.xl))145146            if (allHealthy) {147                Column(148                    modifier = Modifier.fillMaxSize(),149                    horizontalAlignment = Alignment.CenterHorizontally,150                ) {151                    Spacer(Modifier.weight(1f))152                    PeachEmptyOrEndState(153                        message = "Your vault looks healthy.",154                        detail = "No breached, reused, weak, old, or passkey-free logins need attention.",155                    )156                    Spacer(Modifier.weight(1f))157                }158            } else {159                val breachColor = peachColors.Breach160                val dimColor = peachColors.T2161                val buckets = listOf("Breached" to breached, "Reused" to reused, "Weak" to weak, "Old" to old, "No passkey" to noPasskey)162                val expandedRowCount = buckets.sumOf { (title, bucket) -> if (expanded[title] == true) bucket.size else 0 }163                BoxWithConstraints(Modifier.weight(1f).fillMaxWidth()) {164                    // Five short category headers rarely fill the viewport on their165                    // own — center a defined end treatment in whatever's left over166                    // instead of trailing into dead space below the last header.167                    val footerMinHeight = (maxHeight - HEALTH_HEADER_HEIGHT * buckets.size - HEALTH_ENTRY_ROW_HEIGHT * expandedRowCount)168                        .coerceAtLeast(0.dp)169                    LazyColumn(Modifier.fillMaxSize()) {170                        healthSection("Breached", breached, breachColor, expanded, onEditEntry)171                        healthSection("Reused", reused, Amber, expanded, onEditEntry)172                        healthSection("Weak", weak, Amber, expanded, onEditEntry)173                        healthSection("Old", old, dimColor, expanded, onEditEntry)174                        healthSection("No passkey", noPasskey, dimColor, expanded, onEditEntry)175                        item {176                            Column(177                                modifier = Modifier.fillMaxWidth().heightIn(min = footerMinHeight),178                                verticalArrangement = Arrangement.Center,179                            ) {180                                PeachEmptyOrEndState(message = "That's every category.")181                            }182                        }183                    }184                }185            }186        }187    }188}189190private fun androidx.compose.foundation.lazy.LazyListScope.healthSection(191    title: String,192    bucket: List<VaultEntry>,193    badgeColor: Color,194    expanded: androidx.compose.runtime.snapshots.SnapshotStateMap<String, Boolean>,195    onEditEntry: (String) -> Unit,196) {197    item(key = "header_$title") {198        SectionHeader(199            title = title,200            count = bucket.size,201            badgeColor = badgeColor,202            expanded = expanded[title] == true,203            onToggle = { expanded[title] = !(expanded[title] == true) },204        )205    }206    if (expanded[title] == true) {207        items(bucket, key = { "${title}_${it.id}" }) { entry ->208            HealthEntryRow(entry = entry, onChange = { onEditEntry(entry.id) })209        }210    }211}212213@Composable214private fun SectionHeader(215    title: String,216    count: Int,217    badgeColor: Color,218    expanded: Boolean,219    onToggle: () -> Unit,220) {221    Row(222        verticalAlignment = Alignment.CenterVertically,223        modifier = Modifier224            .fillMaxWidth()225            .clip(RoundedCornerShape(10.dp))226            .clickable(onClick = onToggle)227            .padding(vertical = PeachSpacing.sm),228    ) {229        Text(230            title,231            color = if (expanded) peachColors.T0 else peachColors.T1,232            style = TextStyle(233                fontFamily = PeachType.Display, fontStyle = FontStyle.Italic, fontSize = 18.sp,234            ),235        )236        Spacer(Modifier.width(PeachSpacing.sm))237        CountBadge(count, badgeColor)238    }239}240241@Composable242private fun HealthEntryRow(entry: VaultEntry, onChange: () -> Unit) {243    Row(244        verticalAlignment = Alignment.CenterVertically,245        modifier = Modifier.fillMaxWidth().padding(start = PeachSpacing.xs, top = PeachSpacing.sm, bottom = PeachSpacing.sm),246    ) {247        FaviconInitials(source = entry.url ?: entry.title, size = 26.dp)248        Spacer(Modifier.width(PeachSpacing.sm))249        Column(Modifier.weight(1f)) {250            Text(entry.title, color = peachColors.T0, style = PeachType.entryNameList)251            val domain = entry.domainKey()252            if (!domain.isNullOrBlank()) {253                Text(domain, color = peachColors.T2, style = PeachType.tinyMono)254            }255        }256        GhostPill("Change password", LocalPeachAccent.current, onChange)257    }258}259260@Composable261private fun ScoreRing(score: Int, accent: Color) {262    val track = peachColors.T3263    Box(Modifier.fillMaxWidth(), contentAlignment = Alignment.Center) {264        Box(contentAlignment = Alignment.Center) {265            Canvas(modifier = Modifier.size(100.dp)) {266                val stroke = 8.dp.toPx()267                val inset = stroke / 2f268                val arcSize = Size(size.width - stroke, size.height - stroke)269                val topLeft = Offset(inset, inset)270                drawArc(271                    color = track,272                    startAngle = -90f,273                    sweepAngle = 360f,274                    useCenter = false,275                    topLeft = topLeft,276                    size = arcSize,277                    style = Stroke(width = stroke, cap = StrokeCap.Round),278                )279                drawArc(280                    color = accent,281                    startAngle = -90f,282                    sweepAngle = (score.coerceIn(0, 100) / 100f) * 360f,283                    useCenter = false,284                    topLeft = topLeft,285                    size = arcSize,286                    style = Stroke(width = stroke, cap = StrokeCap.Round),287                )288            }289            Column(horizontalAlignment = Alignment.CenterHorizontally) {290                Text(291                    score.toString(),292                    color = peachColors.T0,293                    style = TextStyle(294                        fontFamily = PeachType.Display, fontStyle = FontStyle.Italic, fontSize = 44.sp,295                    ),296                )297                Text(298                    "vault score",299                    color = peachColors.T2,300                    style = PeachType.labelMono.copy(fontSize = 9.sp),301                )302            }303        }304    }305}306307@Composable308private fun CountBadge(count: Int, color: Color) {309    Box(310        modifier = Modifier311            .clip(RoundedCornerShape(999.dp))312            .border(1.dp, color.copy(alpha = 0.4f), RoundedCornerShape(999.dp))313            .padding(horizontal = 9.dp, vertical = 3.dp),314    ) {315        Text(count.toString(), color = color, style = PeachType.sortPill.copy(fontSize = 10.sp))316    }317}318319@Composable320private fun GhostPill(label: String, color: Color, onClick: () -> Unit) {321    Box(322        modifier = Modifier323            .clip(RoundedCornerShape(999.dp))324            .border(1.dp, color.copy(alpha = 0.4f), RoundedCornerShape(999.dp))325            .clickable(onClick = onClick)326            .padding(horizontal = 12.dp, vertical = 5.dp),327    ) {328        Text(label, color = color, style = PeachType.sortPill.copy(fontSize = 10.sp))329    }330}