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.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.BoxWithConstraints 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.heightIn 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.PeachEmptyOrEndState import com.peachpasswords.ui.components.PeachMark import com.peachpasswords.ui.components.PeachSectionHeader import com.peachpasswords.ui.theme.LocalPeachAccent import com.peachpasswords.ui.theme.PeachSpacing import com.peachpasswords.ui.theme.peachColors import com.peachpasswords.ui.theme.PeachType import com.peachpasswords.ui.theme.ShellBackground import com.peachpasswords.license.LicenseRepository 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 /** Approximate on-screen heights used to size the health list's trailing footer. */ private val HEALTH_HEADER_HEIGHT = 52.dp private val HEALTH_ENTRY_ROW_HEIGHT = 64.dp @HiltViewModel class VaultHealthViewModel @Inject constructor( private val vault: VaultRepository, private val license: LicenseRepository, ) : ViewModel() { val entries: StateFlow> = vault.entries private val _report = MutableStateFlow(VaultHealth.buildReport(vault.getEntries())) val report: StateFlow = _report.asStateFlow() private val _advanced = MutableStateFlow(VaultHealth.buildAdvancedReport(vault.getEntries())) val advanced: StateFlow = _advanced.asStateFlow() /** Advanced health is Pro-gated. */ val isPro: Boolean get() = license.hasFeature() /** Re-read the live entry list and recompute the local health reports. */ fun recompute() { val snapshot = vault.getEntries() _report.value = VaultHealth.buildReport(snapshot) _advanced.value = VaultHealth.buildAdvancedReport(snapshot) } } @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 advanced by vm.advanced.collectAsState() val isPro = vm.isPro 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 = PeachSpacing.lg, vertical = PeachSpacing.md), ) { PeachSectionHeader( section = "vault health", onHome = onBack, actions = { GhostPill("check now", accent) { vm.recompute() } }, ) Spacer(Modifier.size(PeachSpacing.lg)) ScoreRing(score = report.vaultScore, accent = accent) Spacer(Modifier.size(PeachSpacing.xl)) if (allHealthy) { Column( modifier = Modifier.fillMaxSize(), horizontalAlignment = Alignment.CenterHorizontally, ) { Spacer(Modifier.weight(1f)) PeachEmptyOrEndState( message = "Your vault looks healthy.", detail = "No breached, reused, weak, old, or passkey-free logins need attention.", ) Spacer(Modifier.weight(1f)) } } else { val breachColor = peachColors.Breach val dimColor = peachColors.T2 val buckets = listOf("Breached" to breached, "Reused" to reused, "Weak" to weak, "Old" to old, "No passkey" to noPasskey) val expandedRowCount = buckets.sumOf { (title, bucket) -> if (expanded[title] == true) bucket.size else 0 } BoxWithConstraints(Modifier.weight(1f).fillMaxWidth()) { // Five short category headers rarely fill the viewport on their // own — center a defined end treatment in whatever's left over // instead of trailing into dead space below the last header. val footerMinHeight = (maxHeight - HEALTH_HEADER_HEIGHT * buckets.size - HEALTH_ENTRY_ROW_HEIGHT * expandedRowCount) .coerceAtLeast(0.dp) LazyColumn(Modifier.fillMaxSize()) { item(key = "advanced") { AdvancedHealthSection( advanced = advanced, isPro = isPro, expanded = expanded["Advanced"] == true, onToggle = { expanded["Advanced"] = !(expanded["Advanced"] == true) }, onEditEntry = onEditEntry, ) } 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) item { Column( modifier = Modifier.fillMaxWidth().heightIn(min = footerMinHeight), verticalArrangement = Arrangement.Center, ) { PeachEmptyOrEndState(message = "That's every category.") } } } } } } } } 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 = PeachSpacing.sm), ) { Text( title, color = if (expanded) peachColors.T0 else peachColors.T1, style = TextStyle( fontFamily = PeachType.Display, fontStyle = FontStyle.Italic, fontSize = 18.sp, ), ) Spacer(Modifier.width(PeachSpacing.sm)) CountBadge(count, badgeColor) } } @Composable private fun HealthEntryRow(entry: VaultEntry, onChange: () -> Unit) { Row( verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth().padding(start = PeachSpacing.xs, top = PeachSpacing.sm, bottom = PeachSpacing.sm), ) { FaviconInitials(source = entry.url ?: entry.title, size = 26.dp) Spacer(Modifier.width(PeachSpacing.sm)) 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 AdvancedHealthSection( advanced: VaultHealth.AdvancedHealthReport, isPro: Boolean, expanded: Boolean, onToggle: () -> Unit, onEditEntry: (String) -> Unit, ) { SectionHeader( title = "Priorities", count = if (isPro) advanced.remediation.size else 0, badgeColor = LocalPeachAccent.current, expanded = expanded, onToggle = onToggle, ) if (expanded) { when { !isPro -> Text( "A ranked fix-it list — reuse, password age, and exposure scoring. Unlock with Peach Pro.", color = peachColors.T2, style = PeachType.tinyMono, modifier = Modifier.fillMaxWidth() .padding(start = PeachSpacing.xs, top = PeachSpacing.sm, bottom = PeachSpacing.sm), ) advanced.remediation.isEmpty() -> Text( "Nothing needs attention.", color = peachColors.T2, style = PeachType.tinyMono, modifier = Modifier.fillMaxWidth() .padding(start = PeachSpacing.xs, top = PeachSpacing.sm, bottom = PeachSpacing.sm), ) else -> advanced.remediation.forEach { r -> AdvancedEntryRow(report = r, onClick = { onEditEntry(r.id) }) } } } } @Composable private fun AdvancedEntryRow(report: VaultHealth.AdvancedEntryReport, onClick: () -> Unit) { Row( verticalAlignment = Alignment.CenterVertically, modifier = Modifier .fillMaxWidth() .clip(RoundedCornerShape(10.dp)) .clickable(onClick = onClick) .padding(start = PeachSpacing.xs, top = PeachSpacing.sm, bottom = PeachSpacing.sm), ) { FaviconInitials(source = report.url ?: report.title, size = 26.dp) Spacer(Modifier.width(PeachSpacing.sm)) Column(Modifier.weight(1f)) { Text(report.title, color = peachColors.T0, style = PeachType.entryNameList) if (report.domain.isNotBlank()) { Text(report.domain, color = peachColors.T2, style = PeachType.tinyMono) } if (report.reasons.isNotEmpty()) { Text( report.reasons.joinToString(" · "), color = LocalPeachAccent.current.copy(alpha = 0.7f), style = PeachType.tinyMono, ) } } CountBadge(report.score, scoreColor(report.score)) } } @Composable private fun scoreColor(score: Int): Color = when { score >= 60 -> peachColors.Breach score >= 25 -> Amber else -> peachColors.T2 } @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)) } }