VaultHealthScreen.kt

16.4 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.vault.VaultEntry57import com.peachpasswords.vault.VaultHealth58import com.peachpasswords.vault.VaultRepository59import com.peachpasswords.vault.domainKey60import com.peachpasswords.vault.hasPasskey61import dagger.hilt.android.lifecycle.HiltViewModel62import kotlinx.coroutines.flow.MutableStateFlow63import kotlinx.coroutines.flow.StateFlow64import kotlinx.coroutines.flow.asStateFlow65import javax.inject.Inject6667/** Warm amber used for the "reused" and "weak" advisory buckets. */68private val Amber = Color(0xFFC8A840)6970/** Local password-age threshold for the "Old" bucket: 365 days. */71private const val OLD_PASSWORD_MS = 365L * 24 * 60 * 60 * 10007273/** Approximate on-screen heights used to size the health list's trailing footer. */74private val HEALTH_HEADER_HEIGHT = 52.dp75private val HEALTH_ENTRY_ROW_HEIGHT = 64.dp7677@HiltViewModel78class VaultHealthViewModel @Inject constructor(79    private val vault: VaultRepository,80    private val license: LicenseRepository,81) : ViewModel() {82    val entries: StateFlow<List<VaultEntry>> = vault.entries8384    private val _report = MutableStateFlow(VaultHealth.buildReport(vault.getEntries()))85    val report: StateFlow<VaultHealth.VaultHealthReport> = _report.asStateFlow()8687    private val _advanced = MutableStateFlow(VaultHealth.buildAdvancedReport(vault.getEntries()))88    val advanced: StateFlow<VaultHealth.AdvancedHealthReport> = _advanced.asStateFlow()8990    /** Advanced health is Pro-gated. */91    val isPro: Boolean get() = license.hasFeature()9293    /** Re-read the live entry list and recompute the local health reports. */94    fun recompute() {95        val snapshot = vault.getEntries()96        _report.value = VaultHealth.buildReport(snapshot)97        _advanced.value = VaultHealth.buildAdvancedReport(snapshot)98    }99}100101@Composable102fun VaultHealthScreen(103    onBack: () -> Unit,104    onEditEntry: (String) -> Unit,105    vm: VaultHealthViewModel = hiltViewModel(),106) {107    val accent = LocalPeachAccent.current108    val entries by vm.entries.collectAsState()109    val report by vm.report.collectAsState()110    val advanced by vm.advanced.collectAsState()111    val isPro = vm.isPro112113    val byId = remember(entries) { entries.associateBy { it.id } }114    val flags = remember(report) { report.entries.associateBy { it.entryId } }115116    val now = System.currentTimeMillis()117    val breached = report.entries.filter { it.isBreach }.mapNotNull { byId[it.entryId] }118    val reused = report.entries.filter { it.isReused }.mapNotNull { byId[it.entryId] }119    val weak = report.entries.filter { it.isWeak }.mapNotNull { byId[it.entryId] }120    val old = entries.filter { e ->121        e.passwordChangedAt?.let { (now - it) > OLD_PASSWORD_MS } == true122    }123    val noPasskey = entries.filter { !it.hasPasskey() }124125    val allHealthy = breached.isEmpty() && reused.isEmpty() &&126        weak.isEmpty() && old.isEmpty() && noPasskey.isEmpty()127128    val expanded = remember { mutableStateMapOf<String, Boolean>() }129130    ShellBackground {131        Column(132            modifier = Modifier133                .fillMaxSize()134                .systemBarsPadding()135                .padding(horizontal = PeachSpacing.lg, vertical = PeachSpacing.md),136        ) {137            PeachSectionHeader(138                section = "vault health",139                onHome = onBack,140                actions = {141                    GhostPill("check now", accent) { vm.recompute() }142                },143            )144145            Spacer(Modifier.size(PeachSpacing.lg))146147            ScoreRing(score = report.vaultScore, accent = accent)148149            Spacer(Modifier.size(PeachSpacing.xl))150151            if (allHealthy) {152                Column(153                    modifier = Modifier.fillMaxSize(),154                    horizontalAlignment = Alignment.CenterHorizontally,155                ) {156                    Spacer(Modifier.weight(1f))157                    PeachEmptyOrEndState(158                        message = "Your vault looks healthy.",159                        detail = "No breached, reused, weak, old, or passkey-free logins need attention.",160                    )161                    Spacer(Modifier.weight(1f))162                }163            } else {164                val breachColor = peachColors.Breach165                val dimColor = peachColors.T2166                val buckets = listOf("Breached" to breached, "Reused" to reused, "Weak" to weak, "Old" to old, "No passkey" to noPasskey)167                val expandedRowCount = buckets.sumOf { (title, bucket) -> if (expanded[title] == true) bucket.size else 0 }168                BoxWithConstraints(Modifier.weight(1f).fillMaxWidth()) {169                    // Five short category headers rarely fill the viewport on their170                    // own — center a defined end treatment in whatever's left over171                    // instead of trailing into dead space below the last header.172                    val footerMinHeight = (maxHeight - HEALTH_HEADER_HEIGHT * buckets.size - HEALTH_ENTRY_ROW_HEIGHT * expandedRowCount)173                        .coerceAtLeast(0.dp)174                    LazyColumn(Modifier.fillMaxSize()) {175                        item(key = "advanced") {176                            AdvancedHealthSection(177                                advanced = advanced,178                                isPro = isPro,179                                expanded = expanded["Advanced"] == true,180                                onToggle = { expanded["Advanced"] = !(expanded["Advanced"] == true) },181                                onEditEntry = onEditEntry,182                            )183                        }184                        healthSection("Breached", breached, breachColor, expanded, onEditEntry)185                        healthSection("Reused", reused, Amber, expanded, onEditEntry)186                        healthSection("Weak", weak, Amber, expanded, onEditEntry)187                        healthSection("Old", old, dimColor, expanded, onEditEntry)188                        healthSection("No passkey", noPasskey, dimColor, expanded, onEditEntry)189                        item {190                            Column(191                                modifier = Modifier.fillMaxWidth().heightIn(min = footerMinHeight),192                                verticalArrangement = Arrangement.Center,193                            ) {194                                PeachEmptyOrEndState(message = "That's every category.")195                            }196                        }197                    }198                }199            }200        }201    }202}203204private fun androidx.compose.foundation.lazy.LazyListScope.healthSection(205    title: String,206    bucket: List<VaultEntry>,207    badgeColor: Color,208    expanded: androidx.compose.runtime.snapshots.SnapshotStateMap<String, Boolean>,209    onEditEntry: (String) -> Unit,210) {211    item(key = "header_$title") {212        SectionHeader(213            title = title,214            count = bucket.size,215            badgeColor = badgeColor,216            expanded = expanded[title] == true,217            onToggle = { expanded[title] = !(expanded[title] == true) },218        )219    }220    if (expanded[title] == true) {221        items(bucket, key = { "${title}_${it.id}" }) { entry ->222            HealthEntryRow(entry = entry, onChange = { onEditEntry(entry.id) })223        }224    }225}226227@Composable228private fun SectionHeader(229    title: String,230    count: Int,231    badgeColor: Color,232    expanded: Boolean,233    onToggle: () -> Unit,234) {235    Row(236        verticalAlignment = Alignment.CenterVertically,237        modifier = Modifier238            .fillMaxWidth()239            .clip(RoundedCornerShape(10.dp))240            .clickable(onClick = onToggle)241            .padding(vertical = PeachSpacing.sm),242    ) {243        Text(244            title,245            color = if (expanded) peachColors.T0 else peachColors.T1,246            style = TextStyle(247                fontFamily = PeachType.Display, fontStyle = FontStyle.Italic, fontSize = 18.sp,248            ),249        )250        Spacer(Modifier.width(PeachSpacing.sm))251        CountBadge(count, badgeColor)252    }253}254255@Composable256private fun HealthEntryRow(entry: VaultEntry, onChange: () -> Unit) {257    Row(258        verticalAlignment = Alignment.CenterVertically,259        modifier = Modifier.fillMaxWidth().padding(start = PeachSpacing.xs, top = PeachSpacing.sm, bottom = PeachSpacing.sm),260    ) {261        FaviconInitials(source = entry.url ?: entry.title, size = 26.dp)262        Spacer(Modifier.width(PeachSpacing.sm))263        Column(Modifier.weight(1f)) {264            Text(entry.title, color = peachColors.T0, style = PeachType.entryNameList)265            val domain = entry.domainKey()266            if (!domain.isNullOrBlank()) {267                Text(domain, color = peachColors.T2, style = PeachType.tinyMono)268            }269        }270        GhostPill("Change password", LocalPeachAccent.current, onChange)271    }272}273274@Composable275private fun AdvancedHealthSection(276    advanced: VaultHealth.AdvancedHealthReport,277    isPro: Boolean,278    expanded: Boolean,279    onToggle: () -> Unit,280    onEditEntry: (String) -> Unit,281) {282    SectionHeader(283        title = "Priorities",284        count = if (isPro) advanced.remediation.size else 0,285        badgeColor = LocalPeachAccent.current,286        expanded = expanded,287        onToggle = onToggle,288    )289    if (expanded) {290        when {291            !isPro -> Text(292                "A ranked fix-it list — reuse, password age, and exposure scoring. Unlock with Peach Pro.",293                color = peachColors.T2,294                style = PeachType.tinyMono,295                modifier = Modifier.fillMaxWidth()296                    .padding(start = PeachSpacing.xs, top = PeachSpacing.sm, bottom = PeachSpacing.sm),297            )298            advanced.remediation.isEmpty() -> Text(299                "Nothing needs attention.",300                color = peachColors.T2,301                style = PeachType.tinyMono,302                modifier = Modifier.fillMaxWidth()303                    .padding(start = PeachSpacing.xs, top = PeachSpacing.sm, bottom = PeachSpacing.sm),304            )305            else -> advanced.remediation.forEach { r ->306                AdvancedEntryRow(report = r, onClick = { onEditEntry(r.id) })307            }308        }309    }310}311312@Composable313private fun AdvancedEntryRow(report: VaultHealth.AdvancedEntryReport, onClick: () -> Unit) {314    Row(315        verticalAlignment = Alignment.CenterVertically,316        modifier = Modifier317            .fillMaxWidth()318            .clip(RoundedCornerShape(10.dp))319            .clickable(onClick = onClick)320            .padding(start = PeachSpacing.xs, top = PeachSpacing.sm, bottom = PeachSpacing.sm),321    ) {322        FaviconInitials(source = report.url ?: report.title, size = 26.dp)323        Spacer(Modifier.width(PeachSpacing.sm))324        Column(Modifier.weight(1f)) {325            Text(report.title, color = peachColors.T0, style = PeachType.entryNameList)326            if (report.domain.isNotBlank()) {327                Text(report.domain, color = peachColors.T2, style = PeachType.tinyMono)328            }329            if (report.reasons.isNotEmpty()) {330                Text(331                    report.reasons.joinToString(" · "),332                    color = LocalPeachAccent.current.copy(alpha = 0.7f),333                    style = PeachType.tinyMono,334                )335            }336        }337        CountBadge(report.score, scoreColor(report.score))338    }339}340341@Composable342private fun scoreColor(score: Int): Color = when {343    score >= 60 -> peachColors.Breach344    score >= 25 -> Amber345    else -> peachColors.T2346}347348@Composable349private fun ScoreRing(score: Int, accent: Color) {350    val track = peachColors.T3351    Box(Modifier.fillMaxWidth(), contentAlignment = Alignment.Center) {352        Box(contentAlignment = Alignment.Center) {353            Canvas(modifier = Modifier.size(100.dp)) {354                val stroke = 8.dp.toPx()355                val inset = stroke / 2f356                val arcSize = Size(size.width - stroke, size.height - stroke)357                val topLeft = Offset(inset, inset)358                drawArc(359                    color = track,360                    startAngle = -90f,361                    sweepAngle = 360f,362                    useCenter = false,363                    topLeft = topLeft,364                    size = arcSize,365                    style = Stroke(width = stroke, cap = StrokeCap.Round),366                )367                drawArc(368                    color = accent,369                    startAngle = -90f,370                    sweepAngle = (score.coerceIn(0, 100) / 100f) * 360f,371                    useCenter = false,372                    topLeft = topLeft,373                    size = arcSize,374                    style = Stroke(width = stroke, cap = StrokeCap.Round),375                )376            }377            Column(horizontalAlignment = Alignment.CenterHorizontally) {378                Text(379                    score.toString(),380                    color = peachColors.T0,381                    style = TextStyle(382                        fontFamily = PeachType.Display, fontStyle = FontStyle.Italic, fontSize = 44.sp,383                    ),384                )385                Text(386                    "vault score",387                    color = peachColors.T2,388                    style = PeachType.labelMono.copy(fontSize = 9.sp),389                )390            }391        }392    }393}394395@Composable396private fun CountBadge(count: Int, color: Color) {397    Box(398        modifier = Modifier399            .clip(RoundedCornerShape(999.dp))400            .border(1.dp, color.copy(alpha = 0.4f), RoundedCornerShape(999.dp))401            .padding(horizontal = 9.dp, vertical = 3.dp),402    ) {403        Text(count.toString(), color = color, style = PeachType.sortPill.copy(fontSize = 10.sp))404    }405}406407@Composable408private fun GhostPill(label: String, color: Color, onClick: () -> Unit) {409    Box(410        modifier = Modifier411            .clip(RoundedCornerShape(999.dp))412            .border(1.dp, color.copy(alpha = 0.4f), RoundedCornerShape(999.dp))413            .clickable(onClick = onClick)414            .padding(horizontal = 12.dp, vertical = 5.dp),415    ) {416        Text(label, color = color, style = PeachType.sortPill.copy(fontSize = 10.sp))417    }418}