VaultHealthScreen.kt

16.8 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.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            PeachTopBar(139                title = "vault health",140                leading = {141                    Icon(142                        Icons.AutoMirrored.Rounded.ArrowBack,143                        contentDescription = "back",144                        tint = peachColors.T0,145                        modifier = Modifier.size(22.dp).clip(RoundedCornerShape(PeachSpacing.sm)).clickable(onClick = onBack),146                    )147                },148                actions = {149                    GhostPill("check now", accent) { vm.recompute() }150                },151            )152153            Spacer(Modifier.size(PeachSpacing.lg))154155            ScoreRing(score = report.vaultScore, accent = accent)156157            Spacer(Modifier.size(PeachSpacing.xl))158159            if (allHealthy) {160                Column(161                    modifier = Modifier.fillMaxSize(),162                    horizontalAlignment = Alignment.CenterHorizontally,163                ) {164                    Spacer(Modifier.weight(1f))165                    PeachEmptyOrEndState(166                        message = "Your vault looks healthy.",167                        detail = "No breached, reused, weak, old, or passkey-free logins need attention.",168                    )169                    Spacer(Modifier.weight(1f))170                }171            } else {172                val breachColor = peachColors.Breach173                val dimColor = peachColors.T2174                val buckets = listOf("Breached" to breached, "Reused" to reused, "Weak" to weak, "Old" to old, "No passkey" to noPasskey)175                val expandedRowCount = buckets.sumOf { (title, bucket) -> if (expanded[title] == true) bucket.size else 0 }176                BoxWithConstraints(Modifier.weight(1f).fillMaxWidth()) {177                    // Five short category headers rarely fill the viewport on their178                    // own — center a defined end treatment in whatever's left over179                    // instead of trailing into dead space below the last header.180                    val footerMinHeight = (maxHeight - HEALTH_HEADER_HEIGHT * buckets.size - HEALTH_ENTRY_ROW_HEIGHT * expandedRowCount)181                        .coerceAtLeast(0.dp)182                    LazyColumn(Modifier.fillMaxSize()) {183                        item(key = "advanced") {184                            AdvancedHealthSection(185                                advanced = advanced,186                                isPro = isPro,187                                expanded = expanded["Advanced"] == true,188                                onToggle = { expanded["Advanced"] = !(expanded["Advanced"] == true) },189                                onEditEntry = onEditEntry,190                            )191                        }192                        healthSection("Breached", breached, breachColor, expanded, onEditEntry)193                        healthSection("Reused", reused, Amber, expanded, onEditEntry)194                        healthSection("Weak", weak, Amber, expanded, onEditEntry)195                        healthSection("Old", old, dimColor, expanded, onEditEntry)196                        healthSection("No passkey", noPasskey, dimColor, expanded, onEditEntry)197                        item {198                            Column(199                                modifier = Modifier.fillMaxWidth().heightIn(min = footerMinHeight),200                                verticalArrangement = Arrangement.Center,201                            ) {202                                PeachEmptyOrEndState(message = "That's every category.")203                            }204                        }205                    }206                }207            }208        }209    }210}211212private fun androidx.compose.foundation.lazy.LazyListScope.healthSection(213    title: String,214    bucket: List<VaultEntry>,215    badgeColor: Color,216    expanded: androidx.compose.runtime.snapshots.SnapshotStateMap<String, Boolean>,217    onEditEntry: (String) -> Unit,218) {219    item(key = "header_$title") {220        SectionHeader(221            title = title,222            count = bucket.size,223            badgeColor = badgeColor,224            expanded = expanded[title] == true,225            onToggle = { expanded[title] = !(expanded[title] == true) },226        )227    }228    if (expanded[title] == true) {229        items(bucket, key = { "${title}_${it.id}" }) { entry ->230            HealthEntryRow(entry = entry, onChange = { onEditEntry(entry.id) })231        }232    }233}234235@Composable236private fun SectionHeader(237    title: String,238    count: Int,239    badgeColor: Color,240    expanded: Boolean,241    onToggle: () -> Unit,242) {243    Row(244        verticalAlignment = Alignment.CenterVertically,245        modifier = Modifier246            .fillMaxWidth()247            .clip(RoundedCornerShape(10.dp))248            .clickable(onClick = onToggle)249            .padding(vertical = PeachSpacing.sm),250    ) {251        Text(252            title,253            color = if (expanded) peachColors.T0 else peachColors.T1,254            style = TextStyle(255                fontFamily = PeachType.Display, fontStyle = FontStyle.Italic, fontSize = 18.sp,256            ),257        )258        Spacer(Modifier.width(PeachSpacing.sm))259        CountBadge(count, badgeColor)260    }261}262263@Composable264private fun HealthEntryRow(entry: VaultEntry, onChange: () -> Unit) {265    Row(266        verticalAlignment = Alignment.CenterVertically,267        modifier = Modifier.fillMaxWidth().padding(start = PeachSpacing.xs, top = PeachSpacing.sm, bottom = PeachSpacing.sm),268    ) {269        FaviconInitials(source = entry.url ?: entry.title, size = 26.dp)270        Spacer(Modifier.width(PeachSpacing.sm))271        Column(Modifier.weight(1f)) {272            Text(entry.title, color = peachColors.T0, style = PeachType.entryNameList)273            val domain = entry.domainKey()274            if (!domain.isNullOrBlank()) {275                Text(domain, color = peachColors.T2, style = PeachType.tinyMono)276            }277        }278        GhostPill("Change password", LocalPeachAccent.current, onChange)279    }280}281282@Composable283private fun AdvancedHealthSection(284    advanced: VaultHealth.AdvancedHealthReport,285    isPro: Boolean,286    expanded: Boolean,287    onToggle: () -> Unit,288    onEditEntry: (String) -> Unit,289) {290    SectionHeader(291        title = "Priorities",292        count = if (isPro) advanced.remediation.size else 0,293        badgeColor = LocalPeachAccent.current,294        expanded = expanded,295        onToggle = onToggle,296    )297    if (expanded) {298        when {299            !isPro -> Text(300                "A ranked fix-it list — reuse, password age, and exposure scoring. Unlock with Peach Pro.",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            advanced.remediation.isEmpty() -> Text(307                "Nothing needs attention.",308                color = peachColors.T2,309                style = PeachType.tinyMono,310                modifier = Modifier.fillMaxWidth()311                    .padding(start = PeachSpacing.xs, top = PeachSpacing.sm, bottom = PeachSpacing.sm),312            )313            else -> advanced.remediation.forEach { r ->314                AdvancedEntryRow(report = r, onClick = { onEditEntry(r.id) })315            }316        }317    }318}319320@Composable321private fun AdvancedEntryRow(report: VaultHealth.AdvancedEntryReport, onClick: () -> Unit) {322    Row(323        verticalAlignment = Alignment.CenterVertically,324        modifier = Modifier325            .fillMaxWidth()326            .clip(RoundedCornerShape(10.dp))327            .clickable(onClick = onClick)328            .padding(start = PeachSpacing.xs, top = PeachSpacing.sm, bottom = PeachSpacing.sm),329    ) {330        FaviconInitials(source = report.url ?: report.title, size = 26.dp)331        Spacer(Modifier.width(PeachSpacing.sm))332        Column(Modifier.weight(1f)) {333            Text(report.title, color = peachColors.T0, style = PeachType.entryNameList)334            if (report.domain.isNotBlank()) {335                Text(report.domain, color = peachColors.T2, style = PeachType.tinyMono)336            }337            if (report.reasons.isNotEmpty()) {338                Text(339                    report.reasons.joinToString(" · "),340                    color = LocalPeachAccent.current.copy(alpha = 0.7f),341                    style = PeachType.tinyMono,342                )343            }344        }345        CountBadge(report.score, scoreColor(report.score))346    }347}348349@Composable350private fun scoreColor(score: Int): Color = when {351    score >= 60 -> peachColors.Breach352    score >= 25 -> Amber353    else -> peachColors.T2354}355356@Composable357private fun ScoreRing(score: Int, accent: Color) {358    val track = peachColors.T3359    Box(Modifier.fillMaxWidth(), contentAlignment = Alignment.Center) {360        Box(contentAlignment = Alignment.Center) {361            Canvas(modifier = Modifier.size(100.dp)) {362                val stroke = 8.dp.toPx()363                val inset = stroke / 2f364                val arcSize = Size(size.width - stroke, size.height - stroke)365                val topLeft = Offset(inset, inset)366                drawArc(367                    color = track,368                    startAngle = -90f,369                    sweepAngle = 360f,370                    useCenter = false,371                    topLeft = topLeft,372                    size = arcSize,373                    style = Stroke(width = stroke, cap = StrokeCap.Round),374                )375                drawArc(376                    color = accent,377                    startAngle = -90f,378                    sweepAngle = (score.coerceIn(0, 100) / 100f) * 360f,379                    useCenter = false,380                    topLeft = topLeft,381                    size = arcSize,382                    style = Stroke(width = stroke, cap = StrokeCap.Round),383                )384            }385            Column(horizontalAlignment = Alignment.CenterHorizontally) {386                Text(387                    score.toString(),388                    color = peachColors.T0,389                    style = TextStyle(390                        fontFamily = PeachType.Display, fontStyle = FontStyle.Italic, fontSize = 44.sp,391                    ),392                )393                Text(394                    "vault score",395                    color = peachColors.T2,396                    style = PeachType.labelMono.copy(fontSize = 9.sp),397                )398            }399        }400    }401}402403@Composable404private fun CountBadge(count: Int, color: Color) {405    Box(406        modifier = Modifier407            .clip(RoundedCornerShape(999.dp))408            .border(1.dp, color.copy(alpha = 0.4f), RoundedCornerShape(999.dp))409            .padding(horizontal = 9.dp, vertical = 3.dp),410    ) {411        Text(count.toString(), color = color, style = PeachType.sortPill.copy(fontSize = 10.sp))412    }413}414415@Composable416private fun GhostPill(label: String, color: Color, onClick: () -> Unit) {417    Box(418        modifier = Modifier419            .clip(RoundedCornerShape(999.dp))420            .border(1.dp, color.copy(alpha = 0.4f), RoundedCornerShape(999.dp))421            .clickable(onClick = onClick)422            .padding(horizontal = 12.dp, vertical = 5.dp),423    ) {424        Text(label, color = color, style = PeachType.sortPill.copy(fontSize = 10.sp))425    }426}