VaultHealthScreen.kt

11.5 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.PeachEmptyOrEndState45import com.peachpasswords.ui.components.PeachMark46import com.peachpasswords.ui.components.PeachTopBar47import com.peachpasswords.ui.theme.LocalPeachAccent48import com.peachpasswords.ui.theme.PeachSpacing49import com.peachpasswords.ui.theme.peachColors50import com.peachpasswords.ui.theme.PeachType51import com.peachpasswords.ui.theme.ShellBackground52import com.peachpasswords.vault.VaultEntry53import com.peachpasswords.vault.VaultHealth54import com.peachpasswords.vault.VaultRepository55import com.peachpasswords.vault.domainKey56import com.peachpasswords.vault.hasPasskey57import dagger.hilt.android.lifecycle.HiltViewModel58import kotlinx.coroutines.flow.MutableStateFlow59import kotlinx.coroutines.flow.StateFlow60import kotlinx.coroutines.flow.asStateFlow61import javax.inject.Inject6263/** Warm amber used for the "reused" and "weak" advisory buckets. */64private val Amber = Color(0xFFC8A840)6566/** Local password-age threshold for the "Old" bucket: 365 days. */67private const val OLD_PASSWORD_MS = 365L * 24 * 60 * 60 * 10006869@HiltViewModel70class VaultHealthViewModel @Inject constructor(71    private val vault: VaultRepository,72) : ViewModel() {73    val entries: StateFlow<List<VaultEntry>> = vault.entries7475    private val _report = MutableStateFlow(VaultHealth.buildReport(vault.getEntries()))76    val report: StateFlow<VaultHealth.VaultHealthReport> = _report.asStateFlow()7778    /** Re-read the live entry list and recompute the local health report. */79    fun recompute() {80        _report.value = VaultHealth.buildReport(vault.getEntries())81    }82}8384@Composable85fun VaultHealthScreen(86    onBack: () -> Unit,87    onEditEntry: (String) -> Unit,88    vm: VaultHealthViewModel = hiltViewModel(),89) {90    val accent = LocalPeachAccent.current91    val entries by vm.entries.collectAsState()92    val report by vm.report.collectAsState()9394    val byId = remember(entries) { entries.associateBy { it.id } }95    val flags = remember(report) { report.entries.associateBy { it.entryId } }9697    val now = System.currentTimeMillis()98    val breached = report.entries.filter { it.isBreach }.mapNotNull { byId[it.entryId] }99    val reused = report.entries.filter { it.isReused }.mapNotNull { byId[it.entryId] }100    val weak = report.entries.filter { it.isWeak }.mapNotNull { byId[it.entryId] }101    val old = entries.filter { e ->102        e.passwordChangedAt?.let { (now - it) > OLD_PASSWORD_MS } == true103    }104    val noPasskey = entries.filter { !it.hasPasskey() }105106    val allHealthy = breached.isEmpty() && reused.isEmpty() &&107        weak.isEmpty() && old.isEmpty() && noPasskey.isEmpty()108109    val expanded = remember { mutableStateMapOf<String, Boolean>() }110111    ShellBackground {112        Column(113            modifier = Modifier114                .fillMaxSize()115                .systemBarsPadding()116                .padding(horizontal = PeachSpacing.lg, vertical = PeachSpacing.md),117        ) {118            PeachTopBar(119                title = "vault health",120                leading = {121                    Icon(122                        Icons.AutoMirrored.Rounded.ArrowBack,123                        contentDescription = "back",124                        tint = peachColors.T0,125                        modifier = Modifier.size(22.dp).clip(RoundedCornerShape(PeachSpacing.sm)).clickable(onClick = onBack),126                    )127                },128                actions = {129                    GhostPill("check now", accent) { vm.recompute() }130                },131            )132133            Spacer(Modifier.size(PeachSpacing.lg))134135            ScoreRing(score = report.vaultScore, accent = accent)136137            Spacer(Modifier.size(PeachSpacing.xl))138139            if (allHealthy) {140                Column(141                    modifier = Modifier.fillMaxSize(),142                    horizontalAlignment = Alignment.CenterHorizontally,143                ) {144                    Spacer(Modifier.weight(1f))145                    PeachEmptyOrEndState(146                        message = "Your vault looks healthy.",147                        detail = "No breached, reused, weak, old, or passkey-free logins need attention.",148                    )149                    Spacer(Modifier.weight(1f))150                }151            } else {152                val breachColor = peachColors.Breach153                val dimColor = peachColors.T2154                LazyColumn(modifier = Modifier.weight(1f)) {155                    healthSection("Breached", breached, breachColor, expanded, onEditEntry)156                    healthSection("Reused", reused, Amber, expanded, onEditEntry)157                    healthSection("Weak", weak, Amber, expanded, onEditEntry)158                    healthSection("Old", old, dimColor, expanded, onEditEntry)159                    healthSection("No passkey", noPasskey, dimColor, expanded, onEditEntry)160                }161            }162        }163    }164}165166private fun androidx.compose.foundation.lazy.LazyListScope.healthSection(167    title: String,168    bucket: List<VaultEntry>,169    badgeColor: Color,170    expanded: androidx.compose.runtime.snapshots.SnapshotStateMap<String, Boolean>,171    onEditEntry: (String) -> Unit,172) {173    item(key = "header_$title") {174        SectionHeader(175            title = title,176            count = bucket.size,177            badgeColor = badgeColor,178            expanded = expanded[title] == true,179            onToggle = { expanded[title] = !(expanded[title] == true) },180        )181    }182    if (expanded[title] == true) {183        items(bucket, key = { "${title}_${it.id}" }) { entry ->184            HealthEntryRow(entry = entry, onChange = { onEditEntry(entry.id) })185        }186    }187}188189@Composable190private fun SectionHeader(191    title: String,192    count: Int,193    badgeColor: Color,194    expanded: Boolean,195    onToggle: () -> Unit,196) {197    Row(198        verticalAlignment = Alignment.CenterVertically,199        modifier = Modifier200            .fillMaxWidth()201            .clip(RoundedCornerShape(10.dp))202            .clickable(onClick = onToggle)203            .padding(vertical = PeachSpacing.sm),204    ) {205        Text(206            title,207            color = if (expanded) peachColors.T0 else peachColors.T1,208            style = TextStyle(209                fontFamily = PeachType.Display, fontStyle = FontStyle.Italic, fontSize = 18.sp,210            ),211        )212        Spacer(Modifier.width(PeachSpacing.sm))213        CountBadge(count, badgeColor)214    }215}216217@Composable218private fun HealthEntryRow(entry: VaultEntry, onChange: () -> Unit) {219    Row(220        verticalAlignment = Alignment.CenterVertically,221        modifier = Modifier.fillMaxWidth().padding(start = PeachSpacing.xs, top = PeachSpacing.sm, bottom = PeachSpacing.sm),222    ) {223        FaviconInitials(source = entry.url ?: entry.title, size = 26.dp)224        Spacer(Modifier.width(PeachSpacing.sm))225        Column(Modifier.weight(1f)) {226            Text(entry.title, color = peachColors.T0, style = PeachType.entryNameList)227            val domain = entry.domainKey()228            if (!domain.isNullOrBlank()) {229                Text(domain, color = peachColors.T2, style = PeachType.tinyMono)230            }231        }232        GhostPill("Change password", LocalPeachAccent.current, onChange)233    }234}235236@Composable237private fun ScoreRing(score: Int, accent: Color) {238    val track = peachColors.T3239    Box(Modifier.fillMaxWidth(), contentAlignment = Alignment.Center) {240        Box(contentAlignment = Alignment.Center) {241            Canvas(modifier = Modifier.size(100.dp)) {242                val stroke = 8.dp.toPx()243                val inset = stroke / 2f244                val arcSize = Size(size.width - stroke, size.height - stroke)245                val topLeft = Offset(inset, inset)246                drawArc(247                    color = track,248                    startAngle = -90f,249                    sweepAngle = 360f,250                    useCenter = false,251                    topLeft = topLeft,252                    size = arcSize,253                    style = Stroke(width = stroke, cap = StrokeCap.Round),254                )255                drawArc(256                    color = accent,257                    startAngle = -90f,258                    sweepAngle = (score.coerceIn(0, 100) / 100f) * 360f,259                    useCenter = false,260                    topLeft = topLeft,261                    size = arcSize,262                    style = Stroke(width = stroke, cap = StrokeCap.Round),263                )264            }265            Column(horizontalAlignment = Alignment.CenterHorizontally) {266                Text(267                    score.toString(),268                    color = peachColors.T0,269                    style = TextStyle(270                        fontFamily = PeachType.Display, fontStyle = FontStyle.Italic, fontSize = 44.sp,271                    ),272                )273                Text(274                    "vault score",275                    color = peachColors.T2,276                    style = PeachType.labelMono.copy(fontSize = 9.sp),277                )278            }279        }280    }281}282283@Composable284private fun CountBadge(count: Int, color: Color) {285    Box(286        modifier = Modifier287            .clip(RoundedCornerShape(999.dp))288            .border(1.dp, color.copy(alpha = 0.4f), RoundedCornerShape(999.dp))289            .padding(horizontal = 9.dp, vertical = 3.dp),290    ) {291        Text(count.toString(), color = color, style = PeachType.sortPill.copy(fontSize = 10.sp))292    }293}294295@Composable296private fun GhostPill(label: String, color: Color, onClick: () -> Unit) {297    Box(298        modifier = Modifier299            .clip(RoundedCornerShape(999.dp))300            .border(1.dp, color.copy(alpha = 0.4f), RoundedCornerShape(999.dp))301            .clickable(onClick = onClick)302            .padding(horizontal = 12.dp, vertical = 5.dp),303    ) {304        Text(label, color = color, style = PeachType.sortPill.copy(fontSize = 10.sp))305    }306}