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.vault.VaultEntry57import com.peachpasswords.vault.VaultHealth58import com.peachpasswords.vault.VaultRepository59import com.peachpasswords.vault.domainKey60import com.peachpasswords.vault.hasPasskey61import dagger.hilt.android.lifecycle.HiltViewModel62import kotlinx.coroutines.flow.MutableStateFlow63import kotlinx.coroutines.flow.StateFlow64import kotlinx.coroutines.flow.asStateFlow65import javax.inject.Inject666768private val Amber = Color(0xFFC8A840)697071private const val OLD_PASSWORD_MS = 365L * 24 * 60 * 60 * 1000727374private val HEALTH_HEADER_HEIGHT = 52.dp75private val HEALTH_ENTRY_ROW_HEIGHT = 64.dp7677@HiltViewModel78class VaultHealthViewModel @Inject constructor(79 private val vault: VaultRepository,80 private val license: LicenseRepository,81) : ViewModel() {82 val entries: StateFlow<List<VaultEntry>> = vault.entries8384 private val _report = MutableStateFlow(VaultHealth.buildReport(vault.getEntries()))85 val report: StateFlow<VaultHealth.VaultHealthReport> = _report.asStateFlow()8687 private val _advanced = MutableStateFlow(VaultHealth.buildAdvancedReport(vault.getEntries()))88 val advanced: StateFlow<VaultHealth.AdvancedHealthReport> = _advanced.asStateFlow()8990 91 val isPro: Boolean get() = license.hasFeature()9293 94 fun recompute() {95 val snapshot = vault.getEntries()96 _report.value = VaultHealth.buildReport(snapshot)97 _advanced.value = VaultHealth.buildAdvancedReport(snapshot)98 }99}100101@Composable102fun VaultHealthScreen(103 onBack: () -> Unit,104 onEditEntry: (String) -> Unit,105 vm: VaultHealthViewModel = hiltViewModel(),106) {107 val accent = LocalPeachAccent.current108 val entries by vm.entries.collectAsState()109 val report by vm.report.collectAsState()110 val advanced by vm.advanced.collectAsState()111 val isPro = vm.isPro112113 val byId = remember(entries) { entries.associateBy { it.id } }114 val flags = remember(report) { report.entries.associateBy { it.entryId } }115116 val now = System.currentTimeMillis()117 val breached = report.entries.filter { it.isBreach }.mapNotNull { byId[it.entryId] }118 val reused = report.entries.filter { it.isReused }.mapNotNull { byId[it.entryId] }119 val weak = report.entries.filter { it.isWeak }.mapNotNull { byId[it.entryId] }120 val old = entries.filter { e ->121 e.passwordChangedAt?.let { (now - it) > OLD_PASSWORD_MS } == true122 }123 val noPasskey = entries.filter { !it.hasPasskey() }124125 val allHealthy = breached.isEmpty() && reused.isEmpty() &&126 weak.isEmpty() && old.isEmpty() && noPasskey.isEmpty()127128 val expanded = remember { mutableStateMapOf<String, Boolean>() }129130 ShellBackground {131 Column(132 modifier = Modifier133 .fillMaxSize()134 .systemBarsPadding()135 .padding(horizontal = PeachSpacing.lg, vertical = PeachSpacing.md),136 ) {137 PeachSectionHeader(138 section = "vault health",139 onHome = onBack,140 actions = {141 GhostPill("check now", accent) { vm.recompute() }142 },143 )144145 Spacer(Modifier.size(PeachSpacing.lg))146147 ScoreRing(score = report.vaultScore, accent = accent)148149 Spacer(Modifier.size(PeachSpacing.xl))150151 if (allHealthy) {152 Column(153 modifier = Modifier.fillMaxSize(),154 horizontalAlignment = Alignment.CenterHorizontally,155 ) {156 Spacer(Modifier.weight(1f))157 PeachEmptyOrEndState(158 message = "Your vault looks healthy.",159 detail = "No breached, reused, weak, old, or passkey-free logins need attention.",160 )161 Spacer(Modifier.weight(1f))162 }163 } else {164 val breachColor = peachColors.Breach165 val dimColor = peachColors.T2166 val buckets = listOf("Breached" to breached, "Reused" to reused, "Weak" to weak, "Old" to old, "No passkey" to noPasskey)167 val expandedRowCount = buckets.sumOf { (title, bucket) -> if (expanded[title] == true) bucket.size else 0 }168 BoxWithConstraints(Modifier.weight(1f).fillMaxWidth()) {169 170 171 172 val footerMinHeight = (maxHeight - HEALTH_HEADER_HEIGHT * buckets.size - HEALTH_ENTRY_ROW_HEIGHT * expandedRowCount)173 .coerceAtLeast(0.dp)174 LazyColumn(Modifier.fillMaxSize()) {175 item(key = "advanced") {176 AdvancedHealthSection(177 advanced = advanced,178 isPro = isPro,179 expanded = expanded["Advanced"] == true,180 onToggle = { expanded["Advanced"] = !(expanded["Advanced"] == true) },181 onEditEntry = onEditEntry,182 )183 }184 healthSection("Breached", breached, breachColor, expanded, onEditEntry)185 healthSection("Reused", reused, Amber, expanded, onEditEntry)186 healthSection("Weak", weak, Amber, expanded, onEditEntry)187 healthSection("Old", old, dimColor, expanded, onEditEntry)188 healthSection("No passkey", noPasskey, dimColor, expanded, onEditEntry)189 item {190 Column(191 modifier = Modifier.fillMaxWidth().heightIn(min = footerMinHeight),192 verticalArrangement = Arrangement.Center,193 ) {194 PeachEmptyOrEndState(message = "That's every category.")195 }196 }197 }198 }199 }200 }201 }202}203204private fun androidx.compose.foundation.lazy.LazyListScope.healthSection(205 title: String,206 bucket: List<VaultEntry>,207 badgeColor: Color,208 expanded: androidx.compose.runtime.snapshots.SnapshotStateMap<String, Boolean>,209 onEditEntry: (String) -> Unit,210) {211 item(key = "header_$title") {212 SectionHeader(213 title = title,214 count = bucket.size,215 badgeColor = badgeColor,216 expanded = expanded[title] == true,217 onToggle = { expanded[title] = !(expanded[title] == true) },218 )219 }220 if (expanded[title] == true) {221 items(bucket, key = { "${title}_${it.id}" }) { entry ->222 HealthEntryRow(entry = entry, onChange = { onEditEntry(entry.id) })223 }224 }225}226227@Composable228private fun SectionHeader(229 title: String,230 count: Int,231 badgeColor: Color,232 expanded: Boolean,233 onToggle: () -> Unit,234) {235 Row(236 verticalAlignment = Alignment.CenterVertically,237 modifier = Modifier238 .fillMaxWidth()239 .clip(RoundedCornerShape(10.dp))240 .clickable(onClick = onToggle)241 .padding(vertical = PeachSpacing.sm),242 ) {243 Text(244 title,245 color = if (expanded) peachColors.T0 else peachColors.T1,246 style = TextStyle(247 fontFamily = PeachType.Display, fontStyle = FontStyle.Italic, fontSize = 18.sp,248 ),249 )250 Spacer(Modifier.width(PeachSpacing.sm))251 CountBadge(count, badgeColor)252 }253}254255@Composable256private fun HealthEntryRow(entry: VaultEntry, onChange: () -> Unit) {257 Row(258 verticalAlignment = Alignment.CenterVertically,259 modifier = Modifier.fillMaxWidth().padding(start = PeachSpacing.xs, top = PeachSpacing.sm, bottom = PeachSpacing.sm),260 ) {261 FaviconInitials(source = entry.url ?: entry.title, size = 26.dp)262 Spacer(Modifier.width(PeachSpacing.sm))263 Column(Modifier.weight(1f)) {264 Text(entry.title, color = peachColors.T0, style = PeachType.entryNameList)265 val domain = entry.domainKey()266 if (!domain.isNullOrBlank()) {267 Text(domain, color = peachColors.T2, style = PeachType.tinyMono)268 }269 }270 GhostPill("Change password", LocalPeachAccent.current, onChange)271 }272}273274@Composable275private fun AdvancedHealthSection(276 advanced: VaultHealth.AdvancedHealthReport,277 isPro: Boolean,278 expanded: Boolean,279 onToggle: () -> Unit,280 onEditEntry: (String) -> Unit,281) {282 SectionHeader(283 title = "Priorities",284 count = if (isPro) advanced.remediation.size else 0,285 badgeColor = LocalPeachAccent.current,286 expanded = expanded,287 onToggle = onToggle,288 )289 if (expanded) {290 when {291 !isPro -> Text(292 "A ranked fix-it list — reuse, password age, and exposure scoring. Unlock with Peach Pro.",293 color = peachColors.T2,294 style = PeachType.tinyMono,295 modifier = Modifier.fillMaxWidth()296 .padding(start = PeachSpacing.xs, top = PeachSpacing.sm, bottom = PeachSpacing.sm),297 )298 advanced.remediation.isEmpty() -> Text(299 "Nothing needs attention.",300 color = peachColors.T2,301 style = PeachType.tinyMono,302 modifier = Modifier.fillMaxWidth()303 .padding(start = PeachSpacing.xs, top = PeachSpacing.sm, bottom = PeachSpacing.sm),304 )305 else -> advanced.remediation.forEach { r ->306 AdvancedEntryRow(report = r, onClick = { onEditEntry(r.id) })307 }308 }309 }310}311312@Composable313private fun AdvancedEntryRow(report: VaultHealth.AdvancedEntryReport, onClick: () -> Unit) {314 Row(315 verticalAlignment = Alignment.CenterVertically,316 modifier = Modifier317 .fillMaxWidth()318 .clip(RoundedCornerShape(10.dp))319 .clickable(onClick = onClick)320 .padding(start = PeachSpacing.xs, top = PeachSpacing.sm, bottom = PeachSpacing.sm),321 ) {322 FaviconInitials(source = report.url ?: report.title, size = 26.dp)323 Spacer(Modifier.width(PeachSpacing.sm))324 Column(Modifier.weight(1f)) {325 Text(report.title, color = peachColors.T0, style = PeachType.entryNameList)326 if (report.domain.isNotBlank()) {327 Text(report.domain, color = peachColors.T2, style = PeachType.tinyMono)328 }329 if (report.reasons.isNotEmpty()) {330 Text(331 report.reasons.joinToString(" · "),332 color = LocalPeachAccent.current.copy(alpha = 0.7f),333 style = PeachType.tinyMono,334 )335 }336 }337 CountBadge(report.score, scoreColor(report.score))338 }339}340341@Composable342private fun scoreColor(score: Int): Color = when {343 score >= 60 -> peachColors.Breach344 score >= 25 -> Amber345 else -> peachColors.T2346}347348@Composable349private fun ScoreRing(score: Int, accent: Color) {350 val track = peachColors.T3351 Box(Modifier.fillMaxWidth(), contentAlignment = Alignment.Center) {352 Box(contentAlignment = Alignment.Center) {353 Canvas(modifier = Modifier.size(100.dp)) {354 val stroke = 8.dp.toPx()355 val inset = stroke / 2f356 val arcSize = Size(size.width - stroke, size.height - stroke)357 val topLeft = Offset(inset, inset)358 drawArc(359 color = track,360 startAngle = -90f,361 sweepAngle = 360f,362 useCenter = false,363 topLeft = topLeft,364 size = arcSize,365 style = Stroke(width = stroke, cap = StrokeCap.Round),366 )367 drawArc(368 color = accent,369 startAngle = -90f,370 sweepAngle = (score.coerceIn(0, 100) / 100f) * 360f,371 useCenter = false,372 topLeft = topLeft,373 size = arcSize,374 style = Stroke(width = stroke, cap = StrokeCap.Round),375 )376 }377 Column(horizontalAlignment = Alignment.CenterHorizontally) {378 Text(379 score.toString(),380 color = peachColors.T0,381 style = TextStyle(382 fontFamily = PeachType.Display, fontStyle = FontStyle.Italic, fontSize = 44.sp,383 ),384 )385 Text(386 "vault score",387 color = peachColors.T2,388 style = PeachType.labelMono.copy(fontSize = 9.sp),389 )390 }391 }392 }393}394395@Composable396private fun CountBadge(count: Int, color: Color) {397 Box(398 modifier = Modifier399 .clip(RoundedCornerShape(999.dp))400 .border(1.dp, color.copy(alpha = 0.4f), RoundedCornerShape(999.dp))401 .padding(horizontal = 9.dp, vertical = 3.dp),402 ) {403 Text(count.toString(), color = color, style = PeachType.sortPill.copy(fontSize = 10.sp))404 }405}406407@Composable408private fun GhostPill(label: String, color: Color, onClick: () -> Unit) {409 Box(410 modifier = Modifier411 .clip(RoundedCornerShape(999.dp))412 .border(1.dp, color.copy(alpha = 0.4f), RoundedCornerShape(999.dp))413 .clickable(onClick = onClick)414 .padding(horizontal = 12.dp, vertical = 5.dp),415 ) {416 Text(label, color = color, style = PeachType.sortPill.copy(fontSize = 10.sp))417 }418}