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.PeachEmptyOrEndState45import com.peachpasswords.ui.components.PeachMark46import com.peachpasswords.ui.components.PeachTopBar47import com.peachpasswords.ui.theme.LocalPeachAccent48import com.peachpasswords.ui.theme.PeachSpacing49import com.peachpasswords.ui.theme.peachColors50import com.peachpasswords.ui.theme.PeachType51import com.peachpasswords.ui.theme.ShellBackground52import com.peachpasswords.vault.VaultEntry53import com.peachpasswords.vault.VaultHealth54import com.peachpasswords.vault.VaultRepository55import com.peachpasswords.vault.domainKey56import com.peachpasswords.vault.hasPasskey57import dagger.hilt.android.lifecycle.HiltViewModel58import kotlinx.coroutines.flow.MutableStateFlow59import kotlinx.coroutines.flow.StateFlow60import kotlinx.coroutines.flow.asStateFlow61import javax.inject.Inject626364private val Amber = Color(0xFFC8A840)656667private const val OLD_PASSWORD_MS = 365L * 24 * 60 * 60 * 10006869@HiltViewModel70class VaultHealthViewModel @Inject constructor(71 private val vault: VaultRepository,72) : ViewModel() {73 val entries: StateFlow<List<VaultEntry>> = vault.entries7475 private val _report = MutableStateFlow(VaultHealth.buildReport(vault.getEntries()))76 val report: StateFlow<VaultHealth.VaultHealthReport> = _report.asStateFlow()7778 79 fun recompute() {80 _report.value = VaultHealth.buildReport(vault.getEntries())81 }82}8384@Composable85fun VaultHealthScreen(86 onBack: () -> Unit,87 onEditEntry: (String) -> Unit,88 vm: VaultHealthViewModel = hiltViewModel(),89) {90 val accent = LocalPeachAccent.current91 val entries by vm.entries.collectAsState()92 val report by vm.report.collectAsState()9394 val byId = remember(entries) { entries.associateBy { it.id } }95 val flags = remember(report) { report.entries.associateBy { it.entryId } }9697 val now = System.currentTimeMillis()98 val breached = report.entries.filter { it.isBreach }.mapNotNull { byId[it.entryId] }99 val reused = report.entries.filter { it.isReused }.mapNotNull { byId[it.entryId] }100 val weak = report.entries.filter { it.isWeak }.mapNotNull { byId[it.entryId] }101 val old = entries.filter { e ->102 e.passwordChangedAt?.let { (now - it) > OLD_PASSWORD_MS } == true103 }104 val noPasskey = entries.filter { !it.hasPasskey() }105106 val allHealthy = breached.isEmpty() && reused.isEmpty() &&107 weak.isEmpty() && old.isEmpty() && noPasskey.isEmpty()108109 val expanded = remember { mutableStateMapOf<String, Boolean>() }110111 ShellBackground {112 Column(113 modifier = Modifier114 .fillMaxSize()115 .systemBarsPadding()116 .padding(horizontal = PeachSpacing.lg, vertical = PeachSpacing.md),117 ) {118 PeachTopBar(119 title = "vault health",120 leading = {121 Icon(122 Icons.AutoMirrored.Rounded.ArrowBack,123 contentDescription = "back",124 tint = peachColors.T0,125 modifier = Modifier.size(22.dp).clip(RoundedCornerShape(PeachSpacing.sm)).clickable(onClick = onBack),126 )127 },128 actions = {129 GhostPill("check now", accent) { vm.recompute() }130 },131 )132133 Spacer(Modifier.size(PeachSpacing.lg))134135 ScoreRing(score = report.vaultScore, accent = accent)136137 Spacer(Modifier.size(PeachSpacing.xl))138139 if (allHealthy) {140 Column(141 modifier = Modifier.fillMaxSize(),142 horizontalAlignment = Alignment.CenterHorizontally,143 ) {144 Spacer(Modifier.weight(1f))145 PeachEmptyOrEndState(146 message = "Your vault looks healthy.",147 detail = "No breached, reused, weak, old, or passkey-free logins need attention.",148 )149 Spacer(Modifier.weight(1f))150 }151 } else {152 val breachColor = peachColors.Breach153 val dimColor = peachColors.T2154 LazyColumn(modifier = Modifier.weight(1f)) {155 healthSection("Breached", breached, breachColor, expanded, onEditEntry)156 healthSection("Reused", reused, Amber, expanded, onEditEntry)157 healthSection("Weak", weak, Amber, expanded, onEditEntry)158 healthSection("Old", old, dimColor, expanded, onEditEntry)159 healthSection("No passkey", noPasskey, dimColor, expanded, onEditEntry)160 }161 }162 }163 }164}165166private fun androidx.compose.foundation.lazy.LazyListScope.healthSection(167 title: String,168 bucket: List<VaultEntry>,169 badgeColor: Color,170 expanded: androidx.compose.runtime.snapshots.SnapshotStateMap<String, Boolean>,171 onEditEntry: (String) -> Unit,172) {173 item(key = "header_$title") {174 SectionHeader(175 title = title,176 count = bucket.size,177 badgeColor = badgeColor,178 expanded = expanded[title] == true,179 onToggle = { expanded[title] = !(expanded[title] == true) },180 )181 }182 if (expanded[title] == true) {183 items(bucket, key = { "${title}_${it.id}" }) { entry ->184 HealthEntryRow(entry = entry, onChange = { onEditEntry(entry.id) })185 }186 }187}188189@Composable190private fun SectionHeader(191 title: String,192 count: Int,193 badgeColor: Color,194 expanded: Boolean,195 onToggle: () -> Unit,196) {197 Row(198 verticalAlignment = Alignment.CenterVertically,199 modifier = Modifier200 .fillMaxWidth()201 .clip(RoundedCornerShape(10.dp))202 .clickable(onClick = onToggle)203 .padding(vertical = PeachSpacing.sm),204 ) {205 Text(206 title,207 color = if (expanded) peachColors.T0 else peachColors.T1,208 style = TextStyle(209 fontFamily = PeachType.Display, fontStyle = FontStyle.Italic, fontSize = 18.sp,210 ),211 )212 Spacer(Modifier.width(PeachSpacing.sm))213 CountBadge(count, badgeColor)214 }215}216217@Composable218private fun HealthEntryRow(entry: VaultEntry, onChange: () -> Unit) {219 Row(220 verticalAlignment = Alignment.CenterVertically,221 modifier = Modifier.fillMaxWidth().padding(start = PeachSpacing.xs, top = PeachSpacing.sm, bottom = PeachSpacing.sm),222 ) {223 FaviconInitials(source = entry.url ?: entry.title, size = 26.dp)224 Spacer(Modifier.width(PeachSpacing.sm))225 Column(Modifier.weight(1f)) {226 Text(entry.title, color = peachColors.T0, style = PeachType.entryNameList)227 val domain = entry.domainKey()228 if (!domain.isNullOrBlank()) {229 Text(domain, color = peachColors.T2, style = PeachType.tinyMono)230 }231 }232 GhostPill("Change password", LocalPeachAccent.current, onChange)233 }234}235236@Composable237private fun ScoreRing(score: Int, accent: Color) {238 val track = peachColors.T3239 Box(Modifier.fillMaxWidth(), contentAlignment = Alignment.Center) {240 Box(contentAlignment = Alignment.Center) {241 Canvas(modifier = Modifier.size(100.dp)) {242 val stroke = 8.dp.toPx()243 val inset = stroke / 2f244 val arcSize = Size(size.width - stroke, size.height - stroke)245 val topLeft = Offset(inset, inset)246 drawArc(247 color = track,248 startAngle = -90f,249 sweepAngle = 360f,250 useCenter = false,251 topLeft = topLeft,252 size = arcSize,253 style = Stroke(width = stroke, cap = StrokeCap.Round),254 )255 drawArc(256 color = accent,257 startAngle = -90f,258 sweepAngle = (score.coerceIn(0, 100) / 100f) * 360f,259 useCenter = false,260 topLeft = topLeft,261 size = arcSize,262 style = Stroke(width = stroke, cap = StrokeCap.Round),263 )264 }265 Column(horizontalAlignment = Alignment.CenterHorizontally) {266 Text(267 score.toString(),268 color = peachColors.T0,269 style = TextStyle(270 fontFamily = PeachType.Display, fontStyle = FontStyle.Italic, fontSize = 44.sp,271 ),272 )273 Text(274 "vault score",275 color = peachColors.T2,276 style = PeachType.labelMono.copy(fontSize = 9.sp),277 )278 }279 }280 }281}282283@Composable284private fun CountBadge(count: Int, color: Color) {285 Box(286 modifier = Modifier287 .clip(RoundedCornerShape(999.dp))288 .border(1.dp, color.copy(alpha = 0.4f), RoundedCornerShape(999.dp))289 .padding(horizontal = 9.dp, vertical = 3.dp),290 ) {291 Text(count.toString(), color = color, style = PeachType.sortPill.copy(fontSize = 10.sp))292 }293}294295@Composable296private fun GhostPill(label: String, color: Color, onClick: () -> Unit) {297 Box(298 modifier = Modifier299 .clip(RoundedCornerShape(999.dp))300 .border(1.dp, color.copy(alpha = 0.4f), RoundedCornerShape(999.dp))301 .clickable(onClick = onClick)302 .padding(horizontal = 12.dp, vertical = 5.dp),303 ) {304 Text(label, color = color, style = PeachType.sortPill.copy(fontSize = 10.sp))305 }306}