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.vault.VaultEntry56import com.peachpasswords.vault.VaultHealth57import com.peachpasswords.vault.VaultRepository58import com.peachpasswords.vault.domainKey59import com.peachpasswords.vault.hasPasskey60import dagger.hilt.android.lifecycle.HiltViewModel61import kotlinx.coroutines.flow.MutableStateFlow62import kotlinx.coroutines.flow.StateFlow63import kotlinx.coroutines.flow.asStateFlow64import javax.inject.Inject656667private val Amber = Color(0xFFC8A840)686970private const val OLD_PASSWORD_MS = 365L * 24 * 60 * 60 * 1000717273private val HEALTH_HEADER_HEIGHT = 52.dp74private val HEALTH_ENTRY_ROW_HEIGHT = 64.dp7576@HiltViewModel77class VaultHealthViewModel @Inject constructor(78 private val vault: VaultRepository,79) : ViewModel() {80 val entries: StateFlow<List<VaultEntry>> = vault.entries8182 private val _report = MutableStateFlow(VaultHealth.buildReport(vault.getEntries()))83 val report: StateFlow<VaultHealth.VaultHealthReport> = _report.asStateFlow()8485 86 fun recompute() {87 _report.value = VaultHealth.buildReport(vault.getEntries())88 }89}9091@Composable92fun VaultHealthScreen(93 onBack: () -> Unit,94 onEditEntry: (String) -> Unit,95 vm: VaultHealthViewModel = hiltViewModel(),96) {97 val accent = LocalPeachAccent.current98 val entries by vm.entries.collectAsState()99 val report by vm.report.collectAsState()100101 val byId = remember(entries) { entries.associateBy { it.id } }102 val flags = remember(report) { report.entries.associateBy { it.entryId } }103104 val now = System.currentTimeMillis()105 val breached = report.entries.filter { it.isBreach }.mapNotNull { byId[it.entryId] }106 val reused = report.entries.filter { it.isReused }.mapNotNull { byId[it.entryId] }107 val weak = report.entries.filter { it.isWeak }.mapNotNull { byId[it.entryId] }108 val old = entries.filter { e ->109 e.passwordChangedAt?.let { (now - it) > OLD_PASSWORD_MS } == true110 }111 val noPasskey = entries.filter { !it.hasPasskey() }112113 val allHealthy = breached.isEmpty() && reused.isEmpty() &&114 weak.isEmpty() && old.isEmpty() && noPasskey.isEmpty()115116 val expanded = remember { mutableStateMapOf<String, Boolean>() }117118 ShellBackground {119 Column(120 modifier = Modifier121 .fillMaxSize()122 .systemBarsPadding()123 .padding(horizontal = PeachSpacing.lg, vertical = PeachSpacing.md),124 ) {125 PeachTopBar(126 title = "vault health",127 leading = {128 Icon(129 Icons.AutoMirrored.Rounded.ArrowBack,130 contentDescription = "back",131 tint = peachColors.T0,132 modifier = Modifier.size(22.dp).clip(RoundedCornerShape(PeachSpacing.sm)).clickable(onClick = onBack),133 )134 },135 actions = {136 GhostPill("check now", accent) { vm.recompute() }137 },138 )139140 Spacer(Modifier.size(PeachSpacing.lg))141142 ScoreRing(score = report.vaultScore, accent = accent)143144 Spacer(Modifier.size(PeachSpacing.xl))145146 if (allHealthy) {147 Column(148 modifier = Modifier.fillMaxSize(),149 horizontalAlignment = Alignment.CenterHorizontally,150 ) {151 Spacer(Modifier.weight(1f))152 PeachEmptyOrEndState(153 message = "Your vault looks healthy.",154 detail = "No breached, reused, weak, old, or passkey-free logins need attention.",155 )156 Spacer(Modifier.weight(1f))157 }158 } else {159 val breachColor = peachColors.Breach160 val dimColor = peachColors.T2161 val buckets = listOf("Breached" to breached, "Reused" to reused, "Weak" to weak, "Old" to old, "No passkey" to noPasskey)162 val expandedRowCount = buckets.sumOf { (title, bucket) -> if (expanded[title] == true) bucket.size else 0 }163 BoxWithConstraints(Modifier.weight(1f).fillMaxWidth()) {164 165 166 167 val footerMinHeight = (maxHeight - HEALTH_HEADER_HEIGHT * buckets.size - HEALTH_ENTRY_ROW_HEIGHT * expandedRowCount)168 .coerceAtLeast(0.dp)169 LazyColumn(Modifier.fillMaxSize()) {170 healthSection("Breached", breached, breachColor, expanded, onEditEntry)171 healthSection("Reused", reused, Amber, expanded, onEditEntry)172 healthSection("Weak", weak, Amber, expanded, onEditEntry)173 healthSection("Old", old, dimColor, expanded, onEditEntry)174 healthSection("No passkey", noPasskey, dimColor, expanded, onEditEntry)175 item {176 Column(177 modifier = Modifier.fillMaxWidth().heightIn(min = footerMinHeight),178 verticalArrangement = Arrangement.Center,179 ) {180 PeachEmptyOrEndState(message = "That's every category.")181 }182 }183 }184 }185 }186 }187 }188}189190private fun androidx.compose.foundation.lazy.LazyListScope.healthSection(191 title: String,192 bucket: List<VaultEntry>,193 badgeColor: Color,194 expanded: androidx.compose.runtime.snapshots.SnapshotStateMap<String, Boolean>,195 onEditEntry: (String) -> Unit,196) {197 item(key = "header_$title") {198 SectionHeader(199 title = title,200 count = bucket.size,201 badgeColor = badgeColor,202 expanded = expanded[title] == true,203 onToggle = { expanded[title] = !(expanded[title] == true) },204 )205 }206 if (expanded[title] == true) {207 items(bucket, key = { "${title}_${it.id}" }) { entry ->208 HealthEntryRow(entry = entry, onChange = { onEditEntry(entry.id) })209 }210 }211}212213@Composable214private fun SectionHeader(215 title: String,216 count: Int,217 badgeColor: Color,218 expanded: Boolean,219 onToggle: () -> Unit,220) {221 Row(222 verticalAlignment = Alignment.CenterVertically,223 modifier = Modifier224 .fillMaxWidth()225 .clip(RoundedCornerShape(10.dp))226 .clickable(onClick = onToggle)227 .padding(vertical = PeachSpacing.sm),228 ) {229 Text(230 title,231 color = if (expanded) peachColors.T0 else peachColors.T1,232 style = TextStyle(233 fontFamily = PeachType.Display, fontStyle = FontStyle.Italic, fontSize = 18.sp,234 ),235 )236 Spacer(Modifier.width(PeachSpacing.sm))237 CountBadge(count, badgeColor)238 }239}240241@Composable242private fun HealthEntryRow(entry: VaultEntry, onChange: () -> Unit) {243 Row(244 verticalAlignment = Alignment.CenterVertically,245 modifier = Modifier.fillMaxWidth().padding(start = PeachSpacing.xs, top = PeachSpacing.sm, bottom = PeachSpacing.sm),246 ) {247 FaviconInitials(source = entry.url ?: entry.title, size = 26.dp)248 Spacer(Modifier.width(PeachSpacing.sm))249 Column(Modifier.weight(1f)) {250 Text(entry.title, color = peachColors.T0, style = PeachType.entryNameList)251 val domain = entry.domainKey()252 if (!domain.isNullOrBlank()) {253 Text(domain, color = peachColors.T2, style = PeachType.tinyMono)254 }255 }256 GhostPill("Change password", LocalPeachAccent.current, onChange)257 }258}259260@Composable261private fun ScoreRing(score: Int, accent: Color) {262 val track = peachColors.T3263 Box(Modifier.fillMaxWidth(), contentAlignment = Alignment.Center) {264 Box(contentAlignment = Alignment.Center) {265 Canvas(modifier = Modifier.size(100.dp)) {266 val stroke = 8.dp.toPx()267 val inset = stroke / 2f268 val arcSize = Size(size.width - stroke, size.height - stroke)269 val topLeft = Offset(inset, inset)270 drawArc(271 color = track,272 startAngle = -90f,273 sweepAngle = 360f,274 useCenter = false,275 topLeft = topLeft,276 size = arcSize,277 style = Stroke(width = stroke, cap = StrokeCap.Round),278 )279 drawArc(280 color = accent,281 startAngle = -90f,282 sweepAngle = (score.coerceIn(0, 100) / 100f) * 360f,283 useCenter = false,284 topLeft = topLeft,285 size = arcSize,286 style = Stroke(width = stroke, cap = StrokeCap.Round),287 )288 }289 Column(horizontalAlignment = Alignment.CenterHorizontally) {290 Text(291 score.toString(),292 color = peachColors.T0,293 style = TextStyle(294 fontFamily = PeachType.Display, fontStyle = FontStyle.Italic, fontSize = 44.sp,295 ),296 )297 Text(298 "vault score",299 color = peachColors.T2,300 style = PeachType.labelMono.copy(fontSize = 9.sp),301 )302 }303 }304 }305}306307@Composable308private fun CountBadge(count: Int, color: Color) {309 Box(310 modifier = Modifier311 .clip(RoundedCornerShape(999.dp))312 .border(1.dp, color.copy(alpha = 0.4f), RoundedCornerShape(999.dp))313 .padding(horizontal = 9.dp, vertical = 3.dp),314 ) {315 Text(count.toString(), color = color, style = PeachType.sortPill.copy(fontSize = 10.sp))316 }317}318319@Composable320private fun GhostPill(label: String, color: Color, onClick: () -> Unit) {321 Box(322 modifier = Modifier323 .clip(RoundedCornerShape(999.dp))324 .border(1.dp, color.copy(alpha = 0.4f), RoundedCornerShape(999.dp))325 .clickable(onClick = onClick)326 .padding(horizontal = 12.dp, vertical = 5.dp),327 ) {328 Text(label, color = color, style = PeachType.sortPill.copy(fontSize = 10.sp))329 }330}