1package com.peachpasswords.ui.vault23import androidx.compose.foundation.Canvas4import androidx.compose.foundation.border5import androidx.compose.foundation.clickable6import androidx.compose.foundation.layout.Box7import androidx.compose.foundation.layout.Column8import androidx.compose.foundation.layout.Row9import androidx.compose.foundation.layout.Spacer10import androidx.compose.foundation.layout.fillMaxSize11import androidx.compose.foundation.layout.fillMaxWidth12import androidx.compose.foundation.layout.padding13import androidx.compose.foundation.layout.size14import androidx.compose.foundation.layout.systemBarsPadding15import androidx.compose.foundation.layout.width16import androidx.compose.foundation.lazy.LazyColumn17import androidx.compose.foundation.lazy.items18import androidx.compose.foundation.shape.RoundedCornerShape19import androidx.compose.material.icons.Icons20import androidx.compose.material.icons.automirrored.rounded.ArrowBack21import androidx.compose.material3.Icon22import androidx.compose.material3.Text23import androidx.compose.runtime.Composable24import androidx.compose.runtime.collectAsState25import androidx.compose.runtime.getValue26import androidx.compose.runtime.mutableStateMapOf27import androidx.compose.runtime.remember28import androidx.compose.ui.Alignment29import androidx.compose.ui.Modifier30import androidx.compose.ui.draw.clip31import androidx.compose.ui.geometry.Offset32import androidx.compose.ui.geometry.Size33import androidx.compose.ui.graphics.Color34import androidx.compose.ui.graphics.StrokeCap35import androidx.compose.ui.graphics.drawscope.Stroke36import androidx.compose.ui.text.TextStyle37import androidx.compose.ui.text.font.FontStyle38import androidx.compose.ui.text.style.TextAlign39import androidx.compose.ui.unit.dp40import androidx.compose.ui.unit.sp41import androidx.hilt.navigation.compose.hiltViewModel42import androidx.lifecycle.ViewModel43import com.peachpasswords.ui.components.FaviconInitials44import com.peachpasswords.ui.components.PeachMark45import com.peachpasswords.ui.theme.LocalPeachAccent46import com.peachpasswords.ui.theme.peachColors47import com.peachpasswords.ui.theme.PeachType48import com.peachpasswords.ui.theme.ShellBackground49import com.peachpasswords.vault.VaultEntry50import com.peachpasswords.vault.VaultHealth51import com.peachpasswords.vault.VaultRepository52import com.peachpasswords.vault.domainKey53import com.peachpasswords.vault.hasPasskey54import dagger.hilt.android.lifecycle.HiltViewModel55import kotlinx.coroutines.flow.MutableStateFlow56import kotlinx.coroutines.flow.StateFlow57import kotlinx.coroutines.flow.asStateFlow58import javax.inject.Inject596061private val Amber = Color(0xFFC8A840)626364private const val OLD_PASSWORD_MS = 365L * 24 * 60 * 60 * 10006566@HiltViewModel67class VaultHealthViewModel @Inject constructor(68 private val vault: VaultRepository,69) : ViewModel() {70 val entries: StateFlow<List<VaultEntry>> = vault.entries7172 private val _report = MutableStateFlow(VaultHealth.buildReport(vault.getEntries()))73 val report: StateFlow<VaultHealth.VaultHealthReport> = _report.asStateFlow()7475 76 fun recompute() {77 _report.value = VaultHealth.buildReport(vault.getEntries())78 }79}8081@Composable82fun VaultHealthScreen(83 onBack: () -> Unit,84 onEditEntry: (String) -> Unit,85 vm: VaultHealthViewModel = hiltViewModel(),86) {87 val accent = LocalPeachAccent.current88 val entries by vm.entries.collectAsState()89 val report by vm.report.collectAsState()9091 val byId = remember(entries) { entries.associateBy { it.id } }92 val flags = remember(report) { report.entries.associateBy { it.entryId } }9394 val now = System.currentTimeMillis()95 val breached = report.entries.filter { it.isBreach }.mapNotNull { byId[it.entryId] }96 val reused = report.entries.filter { it.isReused }.mapNotNull { byId[it.entryId] }97 val weak = report.entries.filter { it.isWeak }.mapNotNull { byId[it.entryId] }98 val old = entries.filter { e ->99 e.passwordChangedAt?.let { (now - it) > OLD_PASSWORD_MS } == true100 }101 val noPasskey = entries.filter { !it.hasPasskey() }102103 val allHealthy = breached.isEmpty() && reused.isEmpty() &&104 weak.isEmpty() && old.isEmpty() && noPasskey.isEmpty()105106 val expanded = remember { mutableStateMapOf<String, Boolean>() }107108 ShellBackground {109 Column(110 modifier = Modifier111 .fillMaxSize()112 .systemBarsPadding()113 .padding(horizontal = 20.dp, vertical = 16.dp),114 ) {115 116 Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth()) {117 Icon(118 Icons.AutoMirrored.Rounded.ArrowBack,119 contentDescription = "back",120 tint = peachColors.T0,121 modifier = Modifier.size(22.dp).clip(RoundedCornerShape(8.dp)).clickable(onClick = onBack),122 )123 Spacer(Modifier.width(14.dp))124 Text(125 "vault health",126 color = peachColors.T0,127 style = TextStyle(128 fontFamily = PeachType.Display, fontStyle = FontStyle.Italic, fontSize = 22.sp,129 ),130 )131 Spacer(Modifier.weight(1f))132 GhostPill("check now", accent) { vm.recompute() }133 }134135 Spacer(Modifier.size(22.dp))136137 ScoreRing(score = report.vaultScore, accent = accent)138139 Spacer(Modifier.size(24.dp))140141 if (allHealthy) {142 Column(143 modifier = Modifier.fillMaxSize(),144 horizontalAlignment = Alignment.CenterHorizontally,145 ) {146 Spacer(Modifier.weight(1f))147 PeachMark(size = 60.dp)148 Spacer(Modifier.size(18.dp))149 Text(150 "Your vault looks healthy.",151 color = peachColors.T1,152 textAlign = TextAlign.Center,153 style = TextStyle(154 fontFamily = PeachType.Display, fontStyle = FontStyle.Italic, fontSize = 20.sp,155 ),156 )157 Spacer(Modifier.weight(1f))158 }159 } else {160 val breachColor = peachColors.Breach161 val dimColor = peachColors.T2162 LazyColumn(modifier = Modifier.weight(1f)) {163 healthSection("Breached", breached, breachColor, expanded, onEditEntry)164 healthSection("Reused", reused, Amber, expanded, onEditEntry)165 healthSection("Weak", weak, Amber, expanded, onEditEntry)166 healthSection("Old", old, dimColor, expanded, onEditEntry)167 healthSection("No passkey", noPasskey, dimColor, expanded, onEditEntry)168 }169 }170 }171 }172}173174private fun androidx.compose.foundation.lazy.LazyListScope.healthSection(175 title: String,176 bucket: List<VaultEntry>,177 badgeColor: Color,178 expanded: androidx.compose.runtime.snapshots.SnapshotStateMap<String, Boolean>,179 onEditEntry: (String) -> Unit,180) {181 item(key = "header_$title") {182 SectionHeader(183 title = title,184 count = bucket.size,185 badgeColor = badgeColor,186 expanded = expanded[title] == true,187 onToggle = { expanded[title] = !(expanded[title] == true) },188 )189 }190 if (expanded[title] == true) {191 items(bucket, key = { "${title}_${it.id}" }) { entry ->192 HealthEntryRow(entry = entry, onChange = { onEditEntry(entry.id) })193 }194 }195}196197@Composable198private fun SectionHeader(199 title: String,200 count: Int,201 badgeColor: Color,202 expanded: Boolean,203 onToggle: () -> Unit,204) {205 Row(206 verticalAlignment = Alignment.CenterVertically,207 modifier = Modifier208 .fillMaxWidth()209 .clip(RoundedCornerShape(10.dp))210 .clickable(onClick = onToggle)211 .padding(vertical = 12.dp),212 ) {213 Text(214 title,215 color = if (expanded) peachColors.T0 else peachColors.T1,216 style = TextStyle(217 fontFamily = PeachType.Display, fontStyle = FontStyle.Italic, fontSize = 18.sp,218 ),219 )220 Spacer(Modifier.width(10.dp))221 CountBadge(count, badgeColor)222 }223}224225@Composable226private fun HealthEntryRow(entry: VaultEntry, onChange: () -> Unit) {227 Row(228 verticalAlignment = Alignment.CenterVertically,229 modifier = Modifier.fillMaxWidth().padding(start = 6.dp, top = 8.dp, bottom = 8.dp),230 ) {231 FaviconInitials(source = entry.url ?: entry.title, size = 26.dp)232 Spacer(Modifier.width(12.dp))233 Column(Modifier.weight(1f)) {234 Text(entry.title, color = peachColors.T0, style = PeachType.entryNameList)235 val domain = entry.domainKey()236 if (!domain.isNullOrBlank()) {237 Text(domain, color = peachColors.T2, style = PeachType.tinyMono)238 }239 }240 GhostPill("Change password", LocalPeachAccent.current, onChange)241 }242}243244@Composable245private fun ScoreRing(score: Int, accent: Color) {246 val track = peachColors.T3247 Box(Modifier.fillMaxWidth(), contentAlignment = Alignment.Center) {248 Box(contentAlignment = Alignment.Center) {249 Canvas(modifier = Modifier.size(100.dp)) {250 val stroke = 8.dp.toPx()251 val inset = stroke / 2f252 val arcSize = Size(size.width - stroke, size.height - stroke)253 val topLeft = Offset(inset, inset)254 drawArc(255 color = track,256 startAngle = -90f,257 sweepAngle = 360f,258 useCenter = false,259 topLeft = topLeft,260 size = arcSize,261 style = Stroke(width = stroke, cap = StrokeCap.Round),262 )263 drawArc(264 color = accent,265 startAngle = -90f,266 sweepAngle = (score.coerceIn(0, 100) / 100f) * 360f,267 useCenter = false,268 topLeft = topLeft,269 size = arcSize,270 style = Stroke(width = stroke, cap = StrokeCap.Round),271 )272 }273 Column(horizontalAlignment = Alignment.CenterHorizontally) {274 Text(275 score.toString(),276 color = peachColors.T0,277 style = TextStyle(278 fontFamily = PeachType.Display, fontStyle = FontStyle.Italic, fontSize = 44.sp,279 ),280 )281 Text(282 "vault score",283 color = peachColors.T2,284 style = PeachType.labelMono.copy(fontSize = 9.sp),285 )286 }287 }288 }289}290291@Composable292private fun CountBadge(count: Int, color: Color) {293 Box(294 modifier = Modifier295 .clip(RoundedCornerShape(999.dp))296 .border(1.dp, color.copy(alpha = 0.4f), RoundedCornerShape(999.dp))297 .padding(horizontal = 9.dp, vertical = 3.dp),298 ) {299 Text(count.toString(), color = color, style = PeachType.sortPill.copy(fontSize = 10.sp))300 }301}302303@Composable304private fun GhostPill(label: String, color: Color, onClick: () -> Unit) {305 Box(306 modifier = Modifier307 .clip(RoundedCornerShape(999.dp))308 .border(1.dp, color.copy(alpha = 0.4f), RoundedCornerShape(999.dp))309 .clickable(onClick = onClick)310 .padding(horizontal = 12.dp, vertical = 5.dp),311 ) {312 Text(label, color = color, style = PeachType.sortPill.copy(fontSize = 10.sp))313 }314}