VaultHealthScreen.kt

16.5 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.PeachSectionHeader50import 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.license.LicenseRepository56import com.peachpasswords.license.ProFeature57import com.peachpasswords.vault.VaultEntry58import com.peachpasswords.vault.VaultHealth59import com.peachpasswords.vault.VaultRepository60import com.peachpasswords.vault.domainKey61import com.peachpasswords.vault.hasPasskey62import dagger.hilt.android.lifecycle.HiltViewModel63import kotlinx.coroutines.flow.MutableStateFlow64import kotlinx.coroutines.flow.StateFlow65import kotlinx.coroutines.flow.asStateFlow66import javax.inject.Inject6768/** Warm amber used for the "reused" and "weak" advisory buckets. */69private val Amber = Color(0xFFC8A840)7071/** Local password-age threshold for the "Old" bucket: 365 days. */72private const val OLD_PASSWORD_MS = 365L * 24 * 60 * 60 * 10007374/** Approximate on-screen heights used to size the health list's trailing footer. */75private val HEALTH_HEADER_HEIGHT = 52.dp76private val HEALTH_ENTRY_ROW_HEIGHT = 64.dp7778@HiltViewModel79class VaultHealthViewModel @Inject constructor(80    private val vault: VaultRepository,81    private val license: LicenseRepository,82) : ViewModel() {83    val entries: StateFlow<List<VaultEntry>> = vault.entries8485    private val _report = MutableStateFlow(VaultHealth.buildReport(vault.getEntries()))86    val report: StateFlow<VaultHealth.VaultHealthReport> = _report.asStateFlow()8788    private val _advanced = MutableStateFlow(VaultHealth.buildAdvancedReport(vault.getEntries()))89    val advanced: StateFlow<VaultHealth.AdvancedHealthReport> = _advanced.asStateFlow()9091    /** Advanced health is Pro-gated (ProFeature.AdvancedHealth). */92    val isPro: Boolean get() = license.hasFeature(ProFeature.AdvancedHealth)9394    /** Re-read the live entry list and recompute the local health reports. */95    fun recompute() {96        val snapshot = vault.getEntries()97        _report.value = VaultHealth.buildReport(snapshot)98        _advanced.value = VaultHealth.buildAdvancedReport(snapshot)99    }100}101102@Composable103fun VaultHealthScreen(104    onBack: () -> Unit,105    onEditEntry: (String) -> Unit,106    vm: VaultHealthViewModel = hiltViewModel(),107) {108    val accent = LocalPeachAccent.current109    val entries by vm.entries.collectAsState()110    val report by vm.report.collectAsState()111    val advanced by vm.advanced.collectAsState()112    val isPro = vm.isPro113114    val byId = remember(entries) { entries.associateBy { it.id } }115    val flags = remember(report) { report.entries.associateBy { it.entryId } }116117    val now = System.currentTimeMillis()118    val breached = report.entries.filter { it.isBreach }.mapNotNull { byId[it.entryId] }119    val reused = report.entries.filter { it.isReused }.mapNotNull { byId[it.entryId] }120    val weak = report.entries.filter { it.isWeak }.mapNotNull { byId[it.entryId] }121    val old = entries.filter { e ->122        e.passwordChangedAt?.let { (now - it) > OLD_PASSWORD_MS } == true123    }124    val noPasskey = entries.filter { !it.hasPasskey() }125126    val allHealthy = breached.isEmpty() && reused.isEmpty() &&127        weak.isEmpty() && old.isEmpty() && noPasskey.isEmpty()128129    val expanded = remember { mutableStateMapOf<String, Boolean>() }130131    ShellBackground {132        Column(133            modifier = Modifier134                .fillMaxSize()135                .systemBarsPadding()136                .padding(horizontal = PeachSpacing.lg, vertical = PeachSpacing.md),137        ) {138            PeachSectionHeader(139                section = "vault health",140                onHome = onBack,141                actions = {142                    GhostPill("check now", accent) { vm.recompute() }143                },144            )145146            Spacer(Modifier.size(PeachSpacing.lg))147148            ScoreRing(score = report.vaultScore, accent = accent)149150            Spacer(Modifier.size(PeachSpacing.xl))151152            if (allHealthy) {153                Column(154                    modifier = Modifier.fillMaxSize(),155                    horizontalAlignment = Alignment.CenterHorizontally,156                ) {157                    Spacer(Modifier.weight(1f))158                    PeachEmptyOrEndState(159                        message = "Your vault looks healthy.",160                        detail = "No breached, reused, weak, old, or passkey-free logins need attention.",161                    )162                    Spacer(Modifier.weight(1f))163                }164            } else {165                val breachColor = peachColors.Breach166                val dimColor = peachColors.T2167                val buckets = listOf("Breached" to breached, "Reused" to reused, "Weak" to weak, "Old" to old, "No passkey" to noPasskey)168                val expandedRowCount = buckets.sumOf { (title, bucket) -> if (expanded[title] == true) bucket.size else 0 }169                BoxWithConstraints(Modifier.weight(1f).fillMaxWidth()) {170                    // Five short category headers rarely fill the viewport on their171                    // own — center a defined end treatment in whatever's left over172                    // instead of trailing into dead space below the last header.173                    val footerMinHeight = (maxHeight - HEALTH_HEADER_HEIGHT * buckets.size - HEALTH_ENTRY_ROW_HEIGHT * expandedRowCount)174                        .coerceAtLeast(0.dp)175                    LazyColumn(Modifier.fillMaxSize()) {176                        item(key = "advanced") {177                            AdvancedHealthSection(178                                advanced = advanced,179                                isPro = isPro,180                                expanded = expanded["Advanced"] == true,181                                onToggle = { expanded["Advanced"] = !(expanded["Advanced"] == true) },182                                onEditEntry = onEditEntry,183                            )184                        }185                        healthSection("Breached", breached, breachColor, expanded, onEditEntry)186                        healthSection("Reused", reused, Amber, expanded, onEditEntry)187                        healthSection("Weak", weak, Amber, expanded, onEditEntry)188                        healthSection("Old", old, dimColor, expanded, onEditEntry)189                        healthSection("No passkey", noPasskey, dimColor, expanded, onEditEntry)190                        item {191                            Column(192                                modifier = Modifier.fillMaxWidth().heightIn(min = footerMinHeight),193                                verticalArrangement = Arrangement.Center,194                            ) {195                                PeachEmptyOrEndState(message = "That's every category.")196                            }197                        }198                    }199                }200            }201        }202    }203}204205private fun androidx.compose.foundation.lazy.LazyListScope.healthSection(206    title: String,207    bucket: List<VaultEntry>,208    badgeColor: Color,209    expanded: androidx.compose.runtime.snapshots.SnapshotStateMap<String, Boolean>,210    onEditEntry: (String) -> Unit,211) {212    item(key = "header_$title") {213        SectionHeader(214            title = title,215            count = bucket.size,216            badgeColor = badgeColor,217            expanded = expanded[title] == true,218            onToggle = { expanded[title] = !(expanded[title] == true) },219        )220    }221    if (expanded[title] == true) {222        items(bucket, key = { "${title}_${it.id}" }) { entry ->223            HealthEntryRow(entry = entry, onChange = { onEditEntry(entry.id) })224        }225    }226}227228@Composable229private fun SectionHeader(230    title: String,231    count: Int,232    badgeColor: Color,233    expanded: Boolean,234    onToggle: () -> Unit,235) {236    Row(237        verticalAlignment = Alignment.CenterVertically,238        modifier = Modifier239            .fillMaxWidth()240            .clip(RoundedCornerShape(10.dp))241            .clickable(onClick = onToggle)242            .padding(vertical = PeachSpacing.sm),243    ) {244        Text(245            title,246            color = if (expanded) peachColors.T0 else peachColors.T1,247            style = TextStyle(248                fontFamily = PeachType.Display, fontStyle = FontStyle.Italic, fontSize = 18.sp,249            ),250        )251        Spacer(Modifier.width(PeachSpacing.sm))252        CountBadge(count, badgeColor)253    }254}255256@Composable257private fun HealthEntryRow(entry: VaultEntry, onChange: () -> Unit) {258    Row(259        verticalAlignment = Alignment.CenterVertically,260        modifier = Modifier.fillMaxWidth().padding(start = PeachSpacing.xs, top = PeachSpacing.sm, bottom = PeachSpacing.sm),261    ) {262        FaviconInitials(source = entry.url ?: entry.title, size = 26.dp)263        Spacer(Modifier.width(PeachSpacing.sm))264        Column(Modifier.weight(1f)) {265            Text(entry.title, color = peachColors.T0, style = PeachType.entryNameList)266            val domain = entry.domainKey()267            if (!domain.isNullOrBlank()) {268                Text(domain, color = peachColors.T2, style = PeachType.tinyMono)269            }270        }271        GhostPill("Change password", LocalPeachAccent.current, onChange)272    }273}274275@Composable276private fun AdvancedHealthSection(277    advanced: VaultHealth.AdvancedHealthReport,278    isPro: Boolean,279    expanded: Boolean,280    onToggle: () -> Unit,281    onEditEntry: (String) -> Unit,282) {283    SectionHeader(284        title = "Priorities",285        count = if (isPro) advanced.remediation.size else 0,286        badgeColor = LocalPeachAccent.current,287        expanded = expanded,288        onToggle = onToggle,289    )290    if (expanded) {291        when {292            !isPro -> Text(293                "A ranked fix-it list — reuse, password age, and exposure scoring. Unlock with Peach Pro.",294                color = peachColors.T2,295                style = PeachType.tinyMono,296                modifier = Modifier.fillMaxWidth()297                    .padding(start = PeachSpacing.xs, top = PeachSpacing.sm, bottom = PeachSpacing.sm),298            )299            advanced.remediation.isEmpty() -> Text(300                "Nothing needs attention.",301                color = peachColors.T2,302                style = PeachType.tinyMono,303                modifier = Modifier.fillMaxWidth()304                    .padding(start = PeachSpacing.xs, top = PeachSpacing.sm, bottom = PeachSpacing.sm),305            )306            else -> advanced.remediation.forEach { r ->307                AdvancedEntryRow(report = r, onClick = { onEditEntry(r.id) })308            }309        }310    }311}312313@Composable314private fun AdvancedEntryRow(report: VaultHealth.AdvancedEntryReport, onClick: () -> Unit) {315    Row(316        verticalAlignment = Alignment.CenterVertically,317        modifier = Modifier318            .fillMaxWidth()319            .clip(RoundedCornerShape(10.dp))320            .clickable(onClick = onClick)321            .padding(start = PeachSpacing.xs, top = PeachSpacing.sm, bottom = PeachSpacing.sm),322    ) {323        FaviconInitials(source = report.url ?: report.title, size = 26.dp)324        Spacer(Modifier.width(PeachSpacing.sm))325        Column(Modifier.weight(1f)) {326            Text(report.title, color = peachColors.T0, style = PeachType.entryNameList)327            if (report.domain.isNotBlank()) {328                Text(report.domain, color = peachColors.T2, style = PeachType.tinyMono)329            }330            if (report.reasons.isNotEmpty()) {331                Text(332                    report.reasons.joinToString(" · "),333                    color = LocalPeachAccent.current.copy(alpha = 0.7f),334                    style = PeachType.tinyMono,335                )336            }337        }338        CountBadge(report.score, scoreColor(report.score))339    }340}341342@Composable343private fun scoreColor(score: Int): Color = when {344    score >= 60 -> peachColors.Breach345    score >= 25 -> Amber346    else -> peachColors.T2347}348349@Composable350private fun ScoreRing(score: Int, accent: Color) {351    val track = peachColors.T3352    Box(Modifier.fillMaxWidth(), contentAlignment = Alignment.Center) {353        Box(contentAlignment = Alignment.Center) {354            Canvas(modifier = Modifier.size(100.dp)) {355                val stroke = 8.dp.toPx()356                val inset = stroke / 2f357                val arcSize = Size(size.width - stroke, size.height - stroke)358                val topLeft = Offset(inset, inset)359                drawArc(360                    color = track,361                    startAngle = -90f,362                    sweepAngle = 360f,363                    useCenter = false,364                    topLeft = topLeft,365                    size = arcSize,366                    style = Stroke(width = stroke, cap = StrokeCap.Round),367                )368                drawArc(369                    color = accent,370                    startAngle = -90f,371                    sweepAngle = (score.coerceIn(0, 100) / 100f) * 360f,372                    useCenter = false,373                    topLeft = topLeft,374                    size = arcSize,375                    style = Stroke(width = stroke, cap = StrokeCap.Round),376                )377            }378            Column(horizontalAlignment = Alignment.CenterHorizontally) {379                Text(380                    score.toString(),381                    color = peachColors.T0,382                    style = TextStyle(383                        fontFamily = PeachType.Display, fontStyle = FontStyle.Italic, fontSize = 44.sp,384                    ),385                )386                Text(387                    "vault score",388                    color = peachColors.T2,389                    style = PeachType.labelMono.copy(fontSize = 9.sp),390                )391            }392        }393    }394}395396@Composable397private fun CountBadge(count: Int, color: Color) {398    Box(399        modifier = Modifier400            .clip(RoundedCornerShape(999.dp))401            .border(1.dp, color.copy(alpha = 0.4f), RoundedCornerShape(999.dp))402            .padding(horizontal = 9.dp, vertical = 3.dp),403    ) {404        Text(count.toString(), color = color, style = PeachType.sortPill.copy(fontSize = 10.sp))405    }406}407408@Composable409private fun GhostPill(label: String, color: Color, onClick: () -> Unit) {410    Box(411        modifier = Modifier412            .clip(RoundedCornerShape(999.dp))413            .border(1.dp, color.copy(alpha = 0.4f), RoundedCornerShape(999.dp))414            .clickable(onClick = onClick)415            .padding(horizontal = 12.dp, vertical = 5.dp),416    ) {417        Text(label, color = color, style = PeachType.sortPill.copy(fontSize = 10.sp))418    }419}