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.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.Inject676869private val Amber = Color(0xFFC8A840)707172private const val OLD_PASSWORD_MS = 365L * 24 * 60 * 60 * 1000737475private 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 92 val isPro: Boolean get() = license.hasFeature(ProFeature.AdvancedHealth)9394 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 PeachSectionHeader(139 section = "vault health",140 onHome = onBack,141 actions = {142 GhostPill("check now", accent) { vm.recompute() }143 },144 )145146 Spacer(Modifier.size(PeachSpacing.lg))147148 ScoreRing(score = report.vaultScore, accent = accent)149150 Spacer(Modifier.size(PeachSpacing.xl))151152 if (allHealthy) {153 Column(154 modifier = Modifier.fillMaxSize(),155 horizontalAlignment = Alignment.CenterHorizontally,156 ) {157 Spacer(Modifier.weight(1f))158 PeachEmptyOrEndState(159 message = "Your vault looks healthy.",160 detail = "No breached, reused, weak, old, or passkey-free logins need attention.",161 )162 Spacer(Modifier.weight(1f))163 }164 } else {165 val breachColor = peachColors.Breach166 val dimColor = peachColors.T2167 val buckets = listOf("Breached" to breached, "Reused" to reused, "Weak" to weak, "Old" to old, "No passkey" to noPasskey)168 val expandedRowCount = buckets.sumOf { (title, bucket) -> if (expanded[title] == true) bucket.size else 0 }169 BoxWithConstraints(Modifier.weight(1f).fillMaxWidth()) {170 171 172 173 val footerMinHeight = (maxHeight - HEALTH_HEADER_HEIGHT * buckets.size - HEALTH_ENTRY_ROW_HEIGHT * expandedRowCount)174 .coerceAtLeast(0.dp)175 LazyColumn(Modifier.fillMaxSize()) {176 item(key = "advanced") {177 AdvancedHealthSection(178 advanced = advanced,179 isPro = isPro,180 expanded = expanded["Advanced"] == true,181 onToggle = { expanded["Advanced"] = !(expanded["Advanced"] == true) },182 onEditEntry = onEditEntry,183 )184 }185 healthSection("Breached", breached, breachColor, expanded, onEditEntry)186 healthSection("Reused", reused, Amber, expanded, onEditEntry)187 healthSection("Weak", weak, Amber, expanded, onEditEntry)188 healthSection("Old", old, dimColor, expanded, onEditEntry)189 healthSection("No passkey", noPasskey, dimColor, expanded, onEditEntry)190 item {191 Column(192 modifier = Modifier.fillMaxWidth().heightIn(min = footerMinHeight),193 verticalArrangement = Arrangement.Center,194 ) {195 PeachEmptyOrEndState(message = "That's every category.")196 }197 }198 }199 }200 }201 }202 }203}204205private fun androidx.compose.foundation.lazy.LazyListScope.healthSection(206 title: String,207 bucket: List<VaultEntry>,208 badgeColor: Color,209 expanded: androidx.compose.runtime.snapshots.SnapshotStateMap<String, Boolean>,210 onEditEntry: (String) -> Unit,211) {212 item(key = "header_$title") {213 SectionHeader(214 title = title,215 count = bucket.size,216 badgeColor = badgeColor,217 expanded = expanded[title] == true,218 onToggle = { expanded[title] = !(expanded[title] == true) },219 )220 }221 if (expanded[title] == true) {222 items(bucket, key = { "${title}_${it.id}" }) { entry ->223 HealthEntryRow(entry = entry, onChange = { onEditEntry(entry.id) })224 }225 }226}227228@Composable229private fun SectionHeader(230 title: String,231 count: Int,232 badgeColor: Color,233 expanded: Boolean,234 onToggle: () -> Unit,235) {236 Row(237 verticalAlignment = Alignment.CenterVertically,238 modifier = Modifier239 .fillMaxWidth()240 .clip(RoundedCornerShape(10.dp))241 .clickable(onClick = onToggle)242 .padding(vertical = PeachSpacing.sm),243 ) {244 Text(245 title,246 color = if (expanded) peachColors.T0 else peachColors.T1,247 style = TextStyle(248 fontFamily = PeachType.Display, fontStyle = FontStyle.Italic, fontSize = 18.sp,249 ),250 )251 Spacer(Modifier.width(PeachSpacing.sm))252 CountBadge(count, badgeColor)253 }254}255256@Composable257private fun HealthEntryRow(entry: VaultEntry, onChange: () -> Unit) {258 Row(259 verticalAlignment = Alignment.CenterVertically,260 modifier = Modifier.fillMaxWidth().padding(start = PeachSpacing.xs, top = PeachSpacing.sm, bottom = PeachSpacing.sm),261 ) {262 FaviconInitials(source = entry.url ?: entry.title, size = 26.dp)263 Spacer(Modifier.width(PeachSpacing.sm))264 Column(Modifier.weight(1f)) {265 Text(entry.title, color = peachColors.T0, style = PeachType.entryNameList)266 val domain = entry.domainKey()267 if (!domain.isNullOrBlank()) {268 Text(domain, color = peachColors.T2, style = PeachType.tinyMono)269 }270 }271 GhostPill("Change password", LocalPeachAccent.current, onChange)272 }273}274275@Composable276private fun AdvancedHealthSection(277 advanced: VaultHealth.AdvancedHealthReport,278 isPro: Boolean,279 expanded: Boolean,280 onToggle: () -> Unit,281 onEditEntry: (String) -> Unit,282) {283 SectionHeader(284 title = "Priorities",285 count = if (isPro) advanced.remediation.size else 0,286 badgeColor = LocalPeachAccent.current,287 expanded = expanded,288 onToggle = onToggle,289 )290 if (expanded) {291 when {292 !isPro -> Text(293 "A ranked fix-it list — reuse, password age, and exposure scoring. Unlock with Peach Pro.",294 color = peachColors.T2,295 style = PeachType.tinyMono,296 modifier = Modifier.fillMaxWidth()297 .padding(start = PeachSpacing.xs, top = PeachSpacing.sm, bottom = PeachSpacing.sm),298 )299 advanced.remediation.isEmpty() -> Text(300 "Nothing needs attention.",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 else -> advanced.remediation.forEach { r ->307 AdvancedEntryRow(report = r, onClick = { onEditEntry(r.id) })308 }309 }310 }311}312313@Composable314private fun AdvancedEntryRow(report: VaultHealth.AdvancedEntryReport, onClick: () -> Unit) {315 Row(316 verticalAlignment = Alignment.CenterVertically,317 modifier = Modifier318 .fillMaxWidth()319 .clip(RoundedCornerShape(10.dp))320 .clickable(onClick = onClick)321 .padding(start = PeachSpacing.xs, top = PeachSpacing.sm, bottom = PeachSpacing.sm),322 ) {323 FaviconInitials(source = report.url ?: report.title, size = 26.dp)324 Spacer(Modifier.width(PeachSpacing.sm))325 Column(Modifier.weight(1f)) {326 Text(report.title, color = peachColors.T0, style = PeachType.entryNameList)327 if (report.domain.isNotBlank()) {328 Text(report.domain, color = peachColors.T2, style = PeachType.tinyMono)329 }330 if (report.reasons.isNotEmpty()) {331 Text(332 report.reasons.joinToString(" · "),333 color = LocalPeachAccent.current.copy(alpha = 0.7f),334 style = PeachType.tinyMono,335 )336 }337 }338 CountBadge(report.score, scoreColor(report.score))339 }340}341342@Composable343private fun scoreColor(score: Int): Color = when {344 score >= 60 -> peachColors.Breach345 score >= 25 -> Amber346 else -> peachColors.T2347}348349@Composable350private fun ScoreRing(score: Int, accent: Color) {351 val track = peachColors.T3352 Box(Modifier.fillMaxWidth(), contentAlignment = Alignment.Center) {353 Box(contentAlignment = Alignment.Center) {354 Canvas(modifier = Modifier.size(100.dp)) {355 val stroke = 8.dp.toPx()356 val inset = stroke / 2f357 val arcSize = Size(size.width - stroke, size.height - stroke)358 val topLeft = Offset(inset, inset)359 drawArc(360 color = track,361 startAngle = -90f,362 sweepAngle = 360f,363 useCenter = false,364 topLeft = topLeft,365 size = arcSize,366 style = Stroke(width = stroke, cap = StrokeCap.Round),367 )368 drawArc(369 color = accent,370 startAngle = -90f,371 sweepAngle = (score.coerceIn(0, 100) / 100f) * 360f,372 useCenter = false,373 topLeft = topLeft,374 size = arcSize,375 style = Stroke(width = stroke, cap = StrokeCap.Round),376 )377 }378 Column(horizontalAlignment = Alignment.CenterHorizontally) {379 Text(380 score.toString(),381 color = peachColors.T0,382 style = TextStyle(383 fontFamily = PeachType.Display, fontStyle = FontStyle.Italic, fontSize = 44.sp,384 ),385 )386 Text(387 "vault score",388 color = peachColors.T2,389 style = PeachType.labelMono.copy(fontSize = 9.sp),390 )391 }392 }393 }394}395396@Composable397private fun CountBadge(count: Int, color: Color) {398 Box(399 modifier = Modifier400 .clip(RoundedCornerShape(999.dp))401 .border(1.dp, color.copy(alpha = 0.4f), RoundedCornerShape(999.dp))402 .padding(horizontal = 9.dp, vertical = 3.dp),403 ) {404 Text(count.toString(), color = color, style = PeachType.sortPill.copy(fontSize = 10.sp))405 }406}407408@Composable409private fun GhostPill(label: String, color: Color, onClick: () -> Unit) {410 Box(411 modifier = Modifier412 .clip(RoundedCornerShape(999.dp))413 .border(1.dp, color.copy(alpha = 0.4f), RoundedCornerShape(999.dp))414 .clickable(onClick = onClick)415 .padding(horizontal = 12.dp, vertical = 5.dp),416 ) {417 Text(label, color = color, style = PeachType.sortPill.copy(fontSize = 10.sp))418 }419}