package com.peachpasswords.ui.vault import androidx.compose.foundation.Canvas import androidx.compose.foundation.border import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.systemBarsPadding import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.rounded.ArrowBack import androidx.compose.material3.Icon import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateMapOf import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.geometry.Offset import androidx.compose.ui.geometry.Size import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.StrokeCap import androidx.compose.ui.graphics.drawscope.Stroke import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.font.FontStyle import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.ViewModel import com.peachpasswords.ui.components.FaviconInitials import com.peachpasswords.ui.components.PeachMark import com.peachpasswords.ui.theme.LocalPeachAccent import com.peachpasswords.ui.theme.peachColors import com.peachpasswords.ui.theme.PeachType import com.peachpasswords.ui.theme.ShellBackground import com.peachpasswords.vault.VaultEntry import com.peachpasswords.vault.VaultHealth import com.peachpasswords.vault.VaultRepository import com.peachpasswords.vault.domainKey import com.peachpasswords.vault.hasPasskey import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import javax.inject.Inject /** Warm amber used for the "reused" and "weak" advisory buckets. */ private val Amber = Color(0xFFC8A840) /** Local password-age threshold for the "Old" bucket: 365 days. */ private const val OLD_PASSWORD_MS = 365L * 24 * 60 * 60 * 1000 @HiltViewModel class VaultHealthViewModel @Inject constructor( private val vault: VaultRepository, ) : ViewModel() { val entries: StateFlow> = vault.entries private val _report = MutableStateFlow(VaultHealth.buildReport(vault.getEntries())) val report: StateFlow = _report.asStateFlow() /** Re-read the live entry list and recompute the local health report. */ fun recompute() { _report.value = VaultHealth.buildReport(vault.getEntries()) } } @Composable fun VaultHealthScreen( onBack: () -> Unit, onEditEntry: (String) -> Unit, vm: VaultHealthViewModel = hiltViewModel(), ) { val accent = LocalPeachAccent.current val entries by vm.entries.collectAsState() val report by vm.report.collectAsState() val byId = remember(entries) { entries.associateBy { it.id } } val flags = remember(report) { report.entries.associateBy { it.entryId } } val now = System.currentTimeMillis() val breached = report.entries.filter { it.isBreach }.mapNotNull { byId[it.entryId] } val reused = report.entries.filter { it.isReused }.mapNotNull { byId[it.entryId] } val weak = report.entries.filter { it.isWeak }.mapNotNull { byId[it.entryId] } val old = entries.filter { e -> e.passwordChangedAt?.let { (now - it) > OLD_PASSWORD_MS } == true } val noPasskey = entries.filter { !it.hasPasskey() } val allHealthy = breached.isEmpty() && reused.isEmpty() && weak.isEmpty() && old.isEmpty() && noPasskey.isEmpty() val expanded = remember { mutableStateMapOf() } ShellBackground { Column( modifier = Modifier .fillMaxSize() .systemBarsPadding() .padding(horizontal = 20.dp, vertical = 16.dp), ) { // Top bar: back · "vault health" · "check now" Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth()) { Icon( Icons.AutoMirrored.Rounded.ArrowBack, contentDescription = "back", tint = peachColors.T0, modifier = Modifier.size(22.dp).clip(RoundedCornerShape(8.dp)).clickable(onClick = onBack), ) Spacer(Modifier.width(14.dp)) Text( "vault health", color = peachColors.T0, style = TextStyle( fontFamily = PeachType.Display, fontStyle = FontStyle.Italic, fontSize = 22.sp, ), ) Spacer(Modifier.weight(1f)) GhostPill("check now", accent) { vm.recompute() } } Spacer(Modifier.size(22.dp)) ScoreRing(score = report.vaultScore, accent = accent) Spacer(Modifier.size(24.dp)) if (allHealthy) { Column( modifier = Modifier.fillMaxSize(), horizontalAlignment = Alignment.CenterHorizontally, ) { Spacer(Modifier.weight(1f)) PeachMark(size = 60.dp) Spacer(Modifier.size(18.dp)) Text( "Your vault looks healthy.", color = peachColors.T1, textAlign = TextAlign.Center, style = TextStyle( fontFamily = PeachType.Display, fontStyle = FontStyle.Italic, fontSize = 20.sp, ), ) Spacer(Modifier.weight(1f)) } } else { val breachColor = peachColors.Breach val dimColor = peachColors.T2 LazyColumn(modifier = Modifier.weight(1f)) { healthSection("Breached", breached, breachColor, expanded, onEditEntry) healthSection("Reused", reused, Amber, expanded, onEditEntry) healthSection("Weak", weak, Amber, expanded, onEditEntry) healthSection("Old", old, dimColor, expanded, onEditEntry) healthSection("No passkey", noPasskey, dimColor, expanded, onEditEntry) } } } } } private fun androidx.compose.foundation.lazy.LazyListScope.healthSection( title: String, bucket: List, badgeColor: Color, expanded: androidx.compose.runtime.snapshots.SnapshotStateMap, onEditEntry: (String) -> Unit, ) { item(key = "header_$title") { SectionHeader( title = title, count = bucket.size, badgeColor = badgeColor, expanded = expanded[title] == true, onToggle = { expanded[title] = !(expanded[title] == true) }, ) } if (expanded[title] == true) { items(bucket, key = { "${title}_${it.id}" }) { entry -> HealthEntryRow(entry = entry, onChange = { onEditEntry(entry.id) }) } } } @Composable private fun SectionHeader( title: String, count: Int, badgeColor: Color, expanded: Boolean, onToggle: () -> Unit, ) { Row( verticalAlignment = Alignment.CenterVertically, modifier = Modifier .fillMaxWidth() .clip(RoundedCornerShape(10.dp)) .clickable(onClick = onToggle) .padding(vertical = 12.dp), ) { Text( title, color = if (expanded) peachColors.T0 else peachColors.T1, style = TextStyle( fontFamily = PeachType.Display, fontStyle = FontStyle.Italic, fontSize = 18.sp, ), ) Spacer(Modifier.width(10.dp)) CountBadge(count, badgeColor) } } @Composable private fun HealthEntryRow(entry: VaultEntry, onChange: () -> Unit) { Row( verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth().padding(start = 6.dp, top = 8.dp, bottom = 8.dp), ) { FaviconInitials(source = entry.url ?: entry.title, size = 26.dp) Spacer(Modifier.width(12.dp)) Column(Modifier.weight(1f)) { Text(entry.title, color = peachColors.T0, style = PeachType.entryNameList) val domain = entry.domainKey() if (!domain.isNullOrBlank()) { Text(domain, color = peachColors.T2, style = PeachType.tinyMono) } } GhostPill("Change password", LocalPeachAccent.current, onChange) } } @Composable private fun ScoreRing(score: Int, accent: Color) { val track = peachColors.T3 Box(Modifier.fillMaxWidth(), contentAlignment = Alignment.Center) { Box(contentAlignment = Alignment.Center) { Canvas(modifier = Modifier.size(100.dp)) { val stroke = 8.dp.toPx() val inset = stroke / 2f val arcSize = Size(size.width - stroke, size.height - stroke) val topLeft = Offset(inset, inset) drawArc( color = track, startAngle = -90f, sweepAngle = 360f, useCenter = false, topLeft = topLeft, size = arcSize, style = Stroke(width = stroke, cap = StrokeCap.Round), ) drawArc( color = accent, startAngle = -90f, sweepAngle = (score.coerceIn(0, 100) / 100f) * 360f, useCenter = false, topLeft = topLeft, size = arcSize, style = Stroke(width = stroke, cap = StrokeCap.Round), ) } Column(horizontalAlignment = Alignment.CenterHorizontally) { Text( score.toString(), color = peachColors.T0, style = TextStyle( fontFamily = PeachType.Display, fontStyle = FontStyle.Italic, fontSize = 44.sp, ), ) Text( "vault score", color = peachColors.T2, style = PeachType.labelMono.copy(fontSize = 9.sp), ) } } } } @Composable private fun CountBadge(count: Int, color: Color) { Box( modifier = Modifier .clip(RoundedCornerShape(999.dp)) .border(1.dp, color.copy(alpha = 0.4f), RoundedCornerShape(999.dp)) .padding(horizontal = 9.dp, vertical = 3.dp), ) { Text(count.toString(), color = color, style = PeachType.sortPill.copy(fontSize = 10.sp)) } } @Composable private fun GhostPill(label: String, color: Color, onClick: () -> Unit) { Box( modifier = Modifier .clip(RoundedCornerShape(999.dp)) .border(1.dp, color.copy(alpha = 0.4f), RoundedCornerShape(999.dp)) .clickable(onClick = onClick) .padding(horizontal = 12.dp, vertical = 5.dp), ) { Text(label, color = color, style = PeachType.sortPill.copy(fontSize = 10.sp)) } }