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.PeachTopBar50import 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 PeachTopBar(139 title = "vault health",140 leading = {141 Icon(142 Icons.AutoMirrored.Rounded.ArrowBack,143 contentDescription = "back",144 tint = peachColors.T0,145 modifier = Modifier.size(22.dp).clip(RoundedCornerShape(PeachSpacing.sm)).clickable(onClick = onBack),146 )147 },148 actions = {149 GhostPill("check now", accent) { vm.recompute() }150 },151 )152153 Spacer(Modifier.size(PeachSpacing.lg))154155 ScoreRing(score = report.vaultScore, accent = accent)156157 Spacer(Modifier.size(PeachSpacing.xl))158159 if (allHealthy) {160 Column(161 modifier = Modifier.fillMaxSize(),162 horizontalAlignment = Alignment.CenterHorizontally,163 ) {164 Spacer(Modifier.weight(1f))165 PeachEmptyOrEndState(166 message = "Your vault looks healthy.",167 detail = "No breached, reused, weak, old, or passkey-free logins need attention.",168 )169 Spacer(Modifier.weight(1f))170 }171 } else {172 val breachColor = peachColors.Breach173 val dimColor = peachColors.T2174 val buckets = listOf("Breached" to breached, "Reused" to reused, "Weak" to weak, "Old" to old, "No passkey" to noPasskey)175 val expandedRowCount = buckets.sumOf { (title, bucket) -> if (expanded[title] == true) bucket.size else 0 }176 BoxWithConstraints(Modifier.weight(1f).fillMaxWidth()) {177 178 179 180 val footerMinHeight = (maxHeight - HEALTH_HEADER_HEIGHT * buckets.size - HEALTH_ENTRY_ROW_HEIGHT * expandedRowCount)181 .coerceAtLeast(0.dp)182 LazyColumn(Modifier.fillMaxSize()) {183 item(key = "advanced") {184 AdvancedHealthSection(185 advanced = advanced,186 isPro = isPro,187 expanded = expanded["Advanced"] == true,188 onToggle = { expanded["Advanced"] = !(expanded["Advanced"] == true) },189 onEditEntry = onEditEntry,190 )191 }192 healthSection("Breached", breached, breachColor, expanded, onEditEntry)193 healthSection("Reused", reused, Amber, expanded, onEditEntry)194 healthSection("Weak", weak, Amber, expanded, onEditEntry)195 healthSection("Old", old, dimColor, expanded, onEditEntry)196 healthSection("No passkey", noPasskey, dimColor, expanded, onEditEntry)197 item {198 Column(199 modifier = Modifier.fillMaxWidth().heightIn(min = footerMinHeight),200 verticalArrangement = Arrangement.Center,201 ) {202 PeachEmptyOrEndState(message = "That's every category.")203 }204 }205 }206 }207 }208 }209 }210}211212private fun androidx.compose.foundation.lazy.LazyListScope.healthSection(213 title: String,214 bucket: List<VaultEntry>,215 badgeColor: Color,216 expanded: androidx.compose.runtime.snapshots.SnapshotStateMap<String, Boolean>,217 onEditEntry: (String) -> Unit,218) {219 item(key = "header_$title") {220 SectionHeader(221 title = title,222 count = bucket.size,223 badgeColor = badgeColor,224 expanded = expanded[title] == true,225 onToggle = { expanded[title] = !(expanded[title] == true) },226 )227 }228 if (expanded[title] == true) {229 items(bucket, key = { "${title}_${it.id}" }) { entry ->230 HealthEntryRow(entry = entry, onChange = { onEditEntry(entry.id) })231 }232 }233}234235@Composable236private fun SectionHeader(237 title: String,238 count: Int,239 badgeColor: Color,240 expanded: Boolean,241 onToggle: () -> Unit,242) {243 Row(244 verticalAlignment = Alignment.CenterVertically,245 modifier = Modifier246 .fillMaxWidth()247 .clip(RoundedCornerShape(10.dp))248 .clickable(onClick = onToggle)249 .padding(vertical = PeachSpacing.sm),250 ) {251 Text(252 title,253 color = if (expanded) peachColors.T0 else peachColors.T1,254 style = TextStyle(255 fontFamily = PeachType.Display, fontStyle = FontStyle.Italic, fontSize = 18.sp,256 ),257 )258 Spacer(Modifier.width(PeachSpacing.sm))259 CountBadge(count, badgeColor)260 }261}262263@Composable264private fun HealthEntryRow(entry: VaultEntry, onChange: () -> Unit) {265 Row(266 verticalAlignment = Alignment.CenterVertically,267 modifier = Modifier.fillMaxWidth().padding(start = PeachSpacing.xs, top = PeachSpacing.sm, bottom = PeachSpacing.sm),268 ) {269 FaviconInitials(source = entry.url ?: entry.title, size = 26.dp)270 Spacer(Modifier.width(PeachSpacing.sm))271 Column(Modifier.weight(1f)) {272 Text(entry.title, color = peachColors.T0, style = PeachType.entryNameList)273 val domain = entry.domainKey()274 if (!domain.isNullOrBlank()) {275 Text(domain, color = peachColors.T2, style = PeachType.tinyMono)276 }277 }278 GhostPill("Change password", LocalPeachAccent.current, onChange)279 }280}281282@Composable283private fun AdvancedHealthSection(284 advanced: VaultHealth.AdvancedHealthReport,285 isPro: Boolean,286 expanded: Boolean,287 onToggle: () -> Unit,288 onEditEntry: (String) -> Unit,289) {290 SectionHeader(291 title = "Priorities",292 count = if (isPro) advanced.remediation.size else 0,293 badgeColor = LocalPeachAccent.current,294 expanded = expanded,295 onToggle = onToggle,296 )297 if (expanded) {298 when {299 !isPro -> Text(300 "A ranked fix-it list — reuse, password age, and exposure scoring. Unlock with Peach Pro.",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 advanced.remediation.isEmpty() -> Text(307 "Nothing needs attention.",308 color = peachColors.T2,309 style = PeachType.tinyMono,310 modifier = Modifier.fillMaxWidth()311 .padding(start = PeachSpacing.xs, top = PeachSpacing.sm, bottom = PeachSpacing.sm),312 )313 else -> advanced.remediation.forEach { r ->314 AdvancedEntryRow(report = r, onClick = { onEditEntry(r.id) })315 }316 }317 }318}319320@Composable321private fun AdvancedEntryRow(report: VaultHealth.AdvancedEntryReport, onClick: () -> Unit) {322 Row(323 verticalAlignment = Alignment.CenterVertically,324 modifier = Modifier325 .fillMaxWidth()326 .clip(RoundedCornerShape(10.dp))327 .clickable(onClick = onClick)328 .padding(start = PeachSpacing.xs, top = PeachSpacing.sm, bottom = PeachSpacing.sm),329 ) {330 FaviconInitials(source = report.url ?: report.title, size = 26.dp)331 Spacer(Modifier.width(PeachSpacing.sm))332 Column(Modifier.weight(1f)) {333 Text(report.title, color = peachColors.T0, style = PeachType.entryNameList)334 if (report.domain.isNotBlank()) {335 Text(report.domain, color = peachColors.T2, style = PeachType.tinyMono)336 }337 if (report.reasons.isNotEmpty()) {338 Text(339 report.reasons.joinToString(" · "),340 color = LocalPeachAccent.current.copy(alpha = 0.7f),341 style = PeachType.tinyMono,342 )343 }344 }345 CountBadge(report.score, scoreColor(report.score))346 }347}348349@Composable350private fun scoreColor(score: Int): Color = when {351 score >= 60 -> peachColors.Breach352 score >= 25 -> Amber353 else -> peachColors.T2354}355356@Composable357private fun ScoreRing(score: Int, accent: Color) {358 val track = peachColors.T3359 Box(Modifier.fillMaxWidth(), contentAlignment = Alignment.Center) {360 Box(contentAlignment = Alignment.Center) {361 Canvas(modifier = Modifier.size(100.dp)) {362 val stroke = 8.dp.toPx()363 val inset = stroke / 2f364 val arcSize = Size(size.width - stroke, size.height - stroke)365 val topLeft = Offset(inset, inset)366 drawArc(367 color = track,368 startAngle = -90f,369 sweepAngle = 360f,370 useCenter = false,371 topLeft = topLeft,372 size = arcSize,373 style = Stroke(width = stroke, cap = StrokeCap.Round),374 )375 drawArc(376 color = accent,377 startAngle = -90f,378 sweepAngle = (score.coerceIn(0, 100) / 100f) * 360f,379 useCenter = false,380 topLeft = topLeft,381 size = arcSize,382 style = Stroke(width = stroke, cap = StrokeCap.Round),383 )384 }385 Column(horizontalAlignment = Alignment.CenterHorizontally) {386 Text(387 score.toString(),388 color = peachColors.T0,389 style = TextStyle(390 fontFamily = PeachType.Display, fontStyle = FontStyle.Italic, fontSize = 44.sp,391 ),392 )393 Text(394 "vault score",395 color = peachColors.T2,396 style = PeachType.labelMono.copy(fontSize = 9.sp),397 )398 }399 }400 }401}402403@Composable404private fun CountBadge(count: Int, color: Color) {405 Box(406 modifier = Modifier407 .clip(RoundedCornerShape(999.dp))408 .border(1.dp, color.copy(alpha = 0.4f), RoundedCornerShape(999.dp))409 .padding(horizontal = 9.dp, vertical = 3.dp),410 ) {411 Text(count.toString(), color = color, style = PeachType.sortPill.copy(fontSize = 10.sp))412 }413}414415@Composable416private fun GhostPill(label: String, color: Color, onClick: () -> Unit) {417 Box(418 modifier = Modifier419 .clip(RoundedCornerShape(999.dp))420 .border(1.dp, color.copy(alpha = 0.4f), RoundedCornerShape(999.dp))421 .clickable(onClick = onClick)422 .padding(horizontal = 12.dp, vertical = 5.dp),423 ) {424 Text(label, color = color, style = PeachType.sortPill.copy(fontSize = 10.sp))425 }426}