1package com.peachpasswords.ui.vault23import androidx.compose.animation.animateColorAsState4import androidx.compose.animation.core.LinearEasing5import androidx.compose.animation.core.animate6import androidx.compose.animation.core.animateFloat7import androidx.compose.animation.core.infiniteRepeatable8import androidx.compose.animation.core.rememberInfiniteTransition9import androidx.compose.animation.core.tween10import androidx.compose.foundation.Canvas11import androidx.compose.foundation.background12import androidx.compose.foundation.border13import androidx.compose.foundation.clickable14import androidx.compose.foundation.combinedClickable15import androidx.compose.foundation.ExperimentalFoundationApi16import androidx.compose.foundation.gestures.detectHorizontalDragGestures17import androidx.compose.foundation.gestures.detectVerticalDragGestures18import androidx.compose.foundation.interaction.MutableInteractionSource19import androidx.compose.foundation.interaction.collectIsPressedAsState20import androidx.compose.foundation.layout.Arrangement21import androidx.compose.foundation.layout.Box22import androidx.compose.foundation.layout.Column23import androidx.compose.foundation.layout.Row24import androidx.compose.foundation.layout.Spacer25import androidx.compose.foundation.layout.fillMaxSize26import androidx.compose.foundation.layout.fillMaxWidth27import androidx.compose.foundation.layout.height28import androidx.compose.foundation.layout.heightIn29import androidx.compose.foundation.layout.padding30import androidx.compose.foundation.layout.size31import androidx.compose.foundation.layout.statusBarsPadding32import androidx.compose.foundation.layout.navigationBarsPadding33import androidx.compose.foundation.layout.width34import androidx.compose.foundation.lazy.LazyColumn35import androidx.compose.foundation.lazy.items36import androidx.compose.foundation.shape.CircleShape37import androidx.compose.foundation.shape.RoundedCornerShape38import androidx.compose.foundation.text.BasicTextField39import androidx.compose.material.icons.Icons40import androidx.compose.material.icons.outlined.Delete41import androidx.compose.material.icons.outlined.Description42import androidx.compose.material.icons.outlined.Settings43import androidx.compose.material.icons.rounded.Add44import androidx.compose.material.icons.rounded.Close45import androidx.compose.material.icons.rounded.Search46import androidx.compose.material.icons.rounded.Settings47import androidx.compose.material3.SmallFloatingActionButton48import androidx.compose.material.icons.outlined.Lock49import androidx.compose.material3.FloatingActionButton50import androidx.compose.material3.Icon51import androidx.compose.material3.TextButton52import androidx.compose.material3.Snackbar53import androidx.compose.material3.SnackbarDuration54import androidx.compose.material3.SnackbarHost55import androidx.compose.material3.SnackbarHostState56import androidx.compose.material3.SnackbarResult57import androidx.compose.material3.Surface58import androidx.compose.material3.Text59import androidx.compose.runtime.Composable60import androidx.compose.runtime.LaunchedEffect61import androidx.compose.runtime.getValue62import androidx.compose.runtime.mutableStateOf63import androidx.compose.runtime.remember64import androidx.compose.runtime.setValue65import androidx.compose.ui.Alignment66import androidx.compose.ui.Modifier67import androidx.compose.ui.draw.clip68import androidx.compose.ui.draw.drawBehind69import androidx.compose.ui.geometry.Offset70import androidx.compose.ui.geometry.Size71import androidx.compose.ui.graphics.Color72import androidx.compose.ui.graphics.SolidColor73import androidx.compose.ui.graphics.StrokeCap74import androidx.compose.ui.graphics.drawscope.Stroke75import androidx.compose.ui.graphics.drawscope.rotate76import androidx.compose.ui.graphics.graphicsLayer77import androidx.compose.ui.input.nestedscroll.NestedScrollConnection78import androidx.compose.ui.input.nestedscroll.NestedScrollSource79import androidx.compose.ui.input.nestedscroll.nestedScroll80import androidx.compose.ui.input.pointer.pointerInput81import androidx.compose.ui.input.pointer.positionChange82import androidx.compose.ui.focus.FocusRequester83import androidx.compose.ui.focus.focusRequester84import androidx.compose.ui.platform.LocalContext85import androidx.compose.ui.platform.LocalDensity86import androidx.compose.ui.text.TextStyle87import androidx.compose.ui.unit.Velocity88import androidx.compose.ui.unit.dp89import androidx.compose.ui.unit.sp90import androidx.hilt.navigation.compose.hiltViewModel91import androidx.compose.runtime.collectAsState92import com.peachpasswords.license.PendingProNotice93import com.peachpasswords.license.proNoticeText94import com.peachpasswords.haptics.PeachHaptics95import com.peachpasswords.sync.SyncIndicator96import com.peachpasswords.sync.SyncOutcomeReason97import com.peachpasswords.sync.SyncOutcomeSummary98import com.peachpasswords.sync.describeSyncOutcome99import com.peachpasswords.sync.syncIndicator100import com.peachpasswords.sync.syncIndicatorLabel101import com.peachpasswords.ui.sync.SyncViewModel102import com.peachpasswords.autofill.AutofillEnablement103import com.peachpasswords.ui.PeachNav104import com.peachpasswords.shared.extractDomain105import com.peachpasswords.ui.components.LocalFavicons106import com.peachpasswords.ui.components.LocalFaviconsEnabled107import com.peachpasswords.ui.components.PeachEmptyOrEndState108import com.peachpasswords.ui.components.PeachFilterPills109import com.peachpasswords.ui.components.PeachSegment110import com.peachpasswords.ui.components.SyncIndicatorDot111import com.peachpasswords.ui.components.FaviconInitials112import com.peachpasswords.ui.theme.LocalPeachAccent113import com.peachpasswords.ui.theme.PeachSpacing114import com.peachpasswords.ui.theme.peachColors115import com.peachpasswords.ui.theme.PeachType116import com.peachpasswords.ui.theme.ShellBackground117import com.peachpasswords.vault.VaultEntry118import com.peachpasswords.vault.hasTOTP119import com.peachpasswords.vault.isNote120import kotlin.math.abs121122@Composable123fun VaultListScreen(124 onNavigate: (PeachNav) -> Unit,125 126 127 128 onBack: (() -> Unit)? = null,129 initialSearchActive: Boolean = false,130 modifier: Modifier = Modifier,131 vm: VaultListViewModel = hiltViewModel(),132) {133 134 135 136 137 138 val autofillContext = LocalContext.current139 LaunchedEffect(Unit) {140 if (AutofillEnablement.shouldOffer(autofillContext)) {141 AutofillEnablement.markAsked(autofillContext)142 runCatching { autofillContext.startActivity(AutofillEnablement.requestIntent(autofillContext)) }143 .onFailure {144 145 runCatching { autofillContext.startActivity(AutofillEnablement.fallbackIntent()) }146 }147 }148 }149 val entries by vm.entries.collectAsState()150151 152 153 154 val favicons = LocalFavicons.current155 val faviconsEnabled = LocalFaviconsEnabled.current156 LaunchedEffect(entries, faviconsEnabled) {157 if (faviconsEnabled) {158 favicons?.prefetch(entries.mapNotNull { it.url?.let(::extractDomain) })159 }160 }161 val sort by vm.sort.collectAsState()162 val search by vm.search.collectAsState()163 val total by vm.totalCount.collectAsState()164 val flashId by vm.flashId.collectAsState()165 val allTags by vm.allTags.collectAsState()166 val activeTagFilter by vm.activeTagFilter.collectAsState()167 val noteOnly by vm.noteOnly.collectAsState()168 val isMultiSelect by vm.isMultiSelectMode.collectAsState()169 val selectedIds by vm.selectedIds.collectAsState()170 val undoEvent by vm.undoEvent.collectAsState()171 val proNotice by vm.proNotice.collectAsState()172 val snackbarHostState = remember { SnackbarHostState() }173 174 val syncVm: SyncViewModel = hiltViewModel()175 val syncStatus by syncVm.status.collectAsState()176 val pairedDevices by syncVm.pairedDevices.collectAsState()177178 LaunchedEffect(undoEvent) {179 val ev = undoEvent ?: return@LaunchedEffect180 vm.consumeUndoEvent()181 val result = snackbarHostState.showSnackbar(182 message = ev.message,183 actionLabel = "UNDO",184 duration = SnackbarDuration.Short,185 )186 if (result == SnackbarResult.ActionPerformed) vm.restoreEntries(ev.ids)187 }188189 190 191 192 193 194 val openedAt = remember { System.currentTimeMillis() }195 LaunchedEffect(syncStatus.finishedAt) {196 val finishedAt = syncStatus.finishedAt ?: return@LaunchedEffect197 if (finishedAt < openedAt) return@LaunchedEffect198 val reason = syncStatus.reason ?: return@LaunchedEffect199 if (!shouldShowSyncOutcomeSnackbar(reason)) return@LaunchedEffect200 snackbarHostState.showSnackbar(201 message = if (reason == SyncOutcomeReason.NO_PAIRED_DEVICES) {202 "No devices paired yet — add one in Settings › Sync"203 } else {204 describeSyncOutcome(syncStatus)205 },206 duration = SnackbarDuration.Short,207 )208 }209210 ShellBackground(modifier) {211 Box(Modifier.fillMaxSize()) {212 Column(Modifier.fillMaxSize()) {213 if (isMultiSelect) {214 MultiSelectTopBar(215 count = selectedIds.size,216 onDelete = { vm.batchSoftDelete() },217 onCancel = { vm.exitMultiSelect() },218 )219 }220 ProNoticeBanner(221 notice = proNotice,222 onLearnMore = {223 vm.dismissProNotice()224 onNavigate(PeachNav.Settings)225 },226 onDismiss = vm::dismissProNotice,227 )228 SearchBar(229 value = search,230 onChange = vm::setSearch,231 count = if (search.isNotBlank()) "${entries.size}/$total" else pluralEntries(total),232 onCollapse = onBack,233 topInset = !isMultiSelect,234 requestFocus = initialSearchActive,235 syncStatus = syncStatus,236 pairedCount = pairedDevices.size,237 )238 if (allTags.isNotEmpty()) {239 TagFilterStrip(240 allTags = allTags,241 activeTagFilter = activeTagFilter,242 onSelect = vm::setTagFilter,243 )244 }245 SortBar(246 active = sort,247 noteOnly = noteOnly,248 onSettings = { onNavigate(PeachNav.Settings) },249 onSort = vm::setSort,250 onToggleNote = vm::toggleNoteFilter,251 onTrash = { onNavigate(PeachNav.Trash) },252 )253 Box(Modifier.fillMaxWidth().weight(1f)) {254 PeachPullToRefresh(255 refreshing = syncStatus.syncing,256 onRefresh = { syncVm.syncNow() },257 modifier = Modifier.fillMaxSize(),258 ) {259 LazyColumn(Modifier.fillMaxSize()) {260 if (entries.isEmpty()) {261 item { EmptyState() }262 }263 items(entries, key = { it.id }) { entry ->264 EntryRow(265 entry = entry,266 flashing = flashId == entry.id,267 canSwipeCopy = vm.canSwipeCopy(entry) && !isMultiSelect,268 multiSelect = isMultiSelect,269 selected = entry.id in selectedIds,270 onTap = {271 if (isMultiSelect) vm.toggleSelected(entry.id)272 else onNavigate(entryTapDestination(entry))273 },274 275 276 277 278 279 280 281 282 283 284 onLongPress = {285 if (isMultiSelect) {286 vm.toggleSelected(entry.id)287 } else {288 vm.enterMultiSelect(entry.id)289 }290 },291 onSwipeCopy = { vm.copyPassword(entry) },292 293 294 295 modifier = Modifier.animateItem(),296 )297 }298 item { Spacer(Modifier.height(88.dp)) }299 }300 }301 Column(302 modifier = Modifier.align(Alignment.BottomEnd)303 .padding(PeachSpacing.lg)304 .navigationBarsPadding(),305 horizontalAlignment = Alignment.CenterHorizontally,306 verticalArrangement = Arrangement.spacedBy(PeachSpacing.md),307 ) {308 SmallFloatingActionButton(309 onClick = vm::lock,310 containerColor = peachColors.S1,311 contentColor = LocalPeachAccent.current,312 ) { Icon(Icons.Outlined.Lock, contentDescription = "Lock vault") }313 FloatingActionButton(314 onClick = {315 if (noteOnly) vm.createNote { id -> onNavigate(PeachNav.NotesMode(id)) }316 else onNavigate(PeachNav.EntryEdit(null))317 },318 containerColor = LocalPeachAccent.current,319 contentColor = peachColors.Bg,320 ) {321 Icon(322 Icons.Rounded.Add,323 contentDescription = if (noteOnly) "new note" else "new entry",324 modifier = Modifier.size(28.dp),325 )326 }327 }328 }329 }330 SnackbarHost(331 hostState = snackbarHostState,332 modifier = Modifier.align(Alignment.BottomCenter).navigationBarsPadding(),333 ) { data ->334 Snackbar(335 containerColor = peachColors.SurfaceElevated,336 contentColor = peachColors.T0,337 actionContentColor = LocalPeachAccent.current,338 snackbarData = data,339 )340 }341 } 342 }343344}345346@Composable347private fun ProNoticeBanner(348 notice: PendingProNotice?,349 onLearnMore: () -> Unit,350 onDismiss: () -> Unit,351) {352 if (notice == null) return353 Row(354 modifier = Modifier355 .fillMaxWidth()356 .background(LocalPeachAccent.current.copy(alpha = 0.10f))357 .border(0.5.dp, LocalPeachAccent.current.copy(alpha = 0.24f))358 .padding(start = 16.dp, top = 4.dp, end = 8.dp, bottom = 4.dp),359 verticalAlignment = Alignment.CenterVertically,360 ) {361 Text(362 text = proNoticeText(notice),363 color = peachColors.T1,364 style = PeachType.labelMono.copy(fontSize = 10.sp),365 modifier = Modifier.weight(1f),366 )367 TextButton(onClick = onLearnMore) {368 Text("Learn more", color = LocalPeachAccent.current, style = PeachType.labelMono.copy(fontSize = 10.sp))369 }370 Icon(371 Icons.Rounded.Close,372 contentDescription = "Dismiss Pro notice",373 tint = peachColors.T2,374 modifier = Modifier375 .size(28.dp)376 .clip(CircleShape)377 .clickable { onDismiss() }378 .padding(7.dp),379 )380 }381}382383internal fun shouldShowSyncOutcomeSnackbar(reason: SyncOutcomeReason): Boolean =384 reason != SyncOutcomeReason.COMPLETED_CHANGES &&385 reason != SyncOutcomeReason.COMPLETED_NO_CHANGES386387private fun pluralEntries(total: Int): String = if (total == 1) "1 entry" else "$total entries"388389@Composable390private fun SearchBar(391 value: String,392 onChange: (String) -> Unit,393 count: String,394 onCollapse: (() -> Unit)? = null,395 topInset: Boolean = false,396 requestFocus: Boolean = false,397 syncStatus: SyncOutcomeSummary? = null,398 pairedCount: Int = 0,399) {400 val accent = LocalPeachAccent.current401 val focusRequester = remember { FocusRequester() }402 LaunchedEffect(requestFocus) {403 if (requestFocus) focusRequester.requestFocus()404 }405 Column(Modifier.fillMaxWidth().then(if (topInset) Modifier.statusBarsPadding() else Modifier)) {406 Row(407 Modifier408 .fillMaxWidth()409 .padding(horizontal = PeachSpacing.md, vertical = PeachSpacing.sm)410 .clip(RoundedCornerShape(12.dp))411 .background(peachColors.S2)412 .padding(horizontal = PeachSpacing.md, vertical = PeachSpacing.sm)413 414 415 416 .then(417 if (onCollapse != null) {418 Modifier.pointerInput(onCollapse) {419 var collapsed = false420 detectVerticalDragGestures(421 onDragEnd = { collapsed = false },422 onDragCancel = { collapsed = false },423 ) { _, dragAmount ->424 if (!collapsed && dragAmount > 4f) {425 collapsed = true426 onCollapse()427 }428 }429 }430 } else {431 Modifier432 },433 ),434 verticalAlignment = Alignment.CenterVertically,435 ) {436 Icon(Icons.Rounded.Search, contentDescription = null, tint = peachColors.T2, modifier = Modifier.size(14.dp))437 Spacer(Modifier.width(PeachSpacing.sm))438 Box(Modifier.weight(1f)) {439 if (value.isEmpty()) {440 Text("search vault…", color = peachColors.T2, style = PeachType.searchInput)441 }442 BasicTextField(443 value = value,444 onValueChange = onChange,445 singleLine = true,446 textStyle = PeachType.searchInput.copy(color = peachColors.T0),447 cursorBrush = SolidColor(accent),448 modifier = Modifier.fillMaxWidth()449 .focusRequester(focusRequester),450 )451 }452 if (syncStatus != null) {453 val indicator = syncIndicator(syncStatus, pairedCount)454 SyncIndicatorDot(455 indicator = indicator,456 label = syncIndicatorLabel(indicator, syncStatus),457 )458 Spacer(Modifier.width(PeachSpacing.sm))459 }460 Text(count, color = peachColors.T2, style = PeachType.tinyMono)461 }462 }463}464465@Composable466private fun SortBar(467 active: SortOrder,468 noteOnly: Boolean,469 onSettings: () -> Unit,470 onSort: (SortOrder) -> Unit,471 onToggleNote: () -> Unit,472 onTrash: () -> Unit,473) {474 val hairline = peachColors.T3475 476 477 478 479 Row(480 modifier = Modifier481 .fillMaxWidth()482 .drawBehind {483 drawRect(484 color = hairline,485 topLeft = Offset(0f, size.height - 1.dp.toPx()),486 size = Size(size.width, 1.dp.toPx()),487 )488 }489 .padding(horizontal = PeachSpacing.md, vertical = PeachSpacing.sm),490 verticalAlignment = Alignment.CenterVertically,491 horizontalArrangement = Arrangement.spacedBy(PeachSpacing.sm),492 ) {493 PeachFilterPills(494 segments = SortOrder.entries.map { PeachSegment(it.name, it.label) },495 selectedKey = active.name,496 onSelected = { key ->497 SortOrder.entries.firstOrNull { it.name == key }?.let(onSort)498 },499 modifier = Modifier.weight(1f),500 )501 ActionCluster(noteOnly, onSettings, onToggleNote, onTrash)502 }503}504505@Composable506private fun ActionCluster(507 noteOnly: Boolean,508 onSettings: () -> Unit,509 onToggleNote: () -> Unit,510 onTrash: () -> Unit,511) {512 val accent = LocalPeachAccent.current513 Surface(514 color = peachColors.S1,515 contentColor = peachColors.T1,516 shape = RoundedCornerShape(999.dp),517 tonalElevation = 1.dp,518 ) {519 Row(520 modifier = Modifier.padding(PeachSpacing.xs),521 verticalAlignment = Alignment.CenterVertically,522 horizontalArrangement = Arrangement.spacedBy(PeachSpacing.xs),523 ) {524 525 526 527 528 529 530 ClusterIcon(531 icon = {532 Icon(533 Icons.Outlined.Description,534 contentDescription = "Notes only",535 tint = if (noteOnly) accent else peachColors.T1,536 )537 },538 onClick = onToggleNote,539 )540 ClusterIcon(541 icon = { Icon(Icons.Outlined.Delete, contentDescription = "trash") },542 onClick = onTrash,543 )544 545 546 ClusterIcon(547 icon = { Icon(Icons.Outlined.Settings, contentDescription = "settings") },548 onClick = onSettings,549 )550 }551 }552}553554@Composable555private fun ClusterIcon(icon: @Composable () -> Unit, onClick: () -> Unit) {556 Box(557 modifier = Modifier558 .size(34.dp)559 .clip(CircleShape)560 .clickable(onClick = onClick)561 .padding(7.dp),562 contentAlignment = Alignment.Center,563 ) {564 androidx.compose.runtime.CompositionLocalProvider(565 androidx.compose.material3.LocalContentColor provides peachColors.T1,566 content = icon,567 )568 }569}570571@OptIn(ExperimentalFoundationApi::class)572@Composable573private fun EntryRow(574 entry: VaultEntry,575 flashing: Boolean,576 canSwipeCopy: Boolean,577 multiSelect: Boolean,578 selected: Boolean,579 onTap: () -> Unit,580 onLongPress: () -> Unit,581 onSwipeCopy: () -> Unit,582 modifier: Modifier = Modifier,583) {584 val context = LocalContext.current585 val accent = LocalPeachAccent.current586 val interaction = remember { MutableInteractionSource() }587 val pressed by interaction.collectIsPressedAsState()588 val rowBg by animateColorAsState(589 targetValue = when {590 flashing -> accent.copy(alpha = 0.32f)591 pressed -> peachColors.T4592 else -> Color.Transparent593 },594 label = "row-bg",595 )596 val meta = remember(entry) { entry.domainKeyOrUser() }597598 Row(599 modifier = modifier600 .fillMaxWidth()601 .clip(RoundedCornerShape(7.dp))602 .background(rowBg)603 604 605 606 607 608 609 610 611 612 613 .combinedClickable(614 interactionSource = interaction,615 indication = null,616 onClick = onTap,617 onLongClickLabel = "Select",618 onLongClick = {619 PeachHaptics.onLongPress(context)620 onLongPress()621 },622 )623 .pointerInput(entry.id, canSwipeCopy) {624 if (!canSwipeCopy) return@pointerInput625 var dx = 0f626 var dy = 0f627 var startTime = 0L628 detectHorizontalDragGestures(629 onDragStart = { dx = 0f; dy = 0f; startTime = System.currentTimeMillis() },630 onDragEnd = {631 val dt = System.currentTimeMillis() - startTime632 if (dx > 80.dp.toPx() && abs(dy) < 30.dp.toPx() && dt < 400) {633 PeachHaptics.onSwipeCopy(context)634 onSwipeCopy()635 }636 },637 onHorizontalDrag = { change, amount -> dx += amount; dy += change.positionChange().y },638 )639 }640 .padding(horizontal = 16.dp, vertical = 7.dp),641 horizontalArrangement = Arrangement.spacedBy(10.dp),642 verticalAlignment = Alignment.CenterVertically,643 ) {644 if (multiSelect) {645 MultiSelectCheckbox(checked = selected)646 }647 FaviconInitials(source = entry.url ?: entry.title, size = 36.dp)648 Column(Modifier.weight(1f)) {649 Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) {650 Text(651 text = entry.title,652 color = peachColors.T0,653 style = PeachType.entryNameList,654 maxLines = 1,655 modifier = Modifier.weight(1f, fill = false),656 )657 entry.tags?.takeIf { it.isNotEmpty() }?.let { tags -> EntryRowTags(tags) }658 }659 if (meta.isNotEmpty()) {660 Text(661 text = meta,662 color = peachColors.T2,663 style = TextStyle(fontFamily = PeachType.Mono, fontSize = 9.sp),664 maxLines = 1,665 )666 }667 }668 if (entry.hasTOTP()) {669 Box(670 Modifier671 .clip(RoundedCornerShape(4.dp))672 .background(peachColors.TotpTag.copy(alpha = 0.14f))673 .border(0.5.dp, peachColors.TotpTag.copy(alpha = 0.30f), RoundedCornerShape(4.dp))674 .padding(horizontal = 6.dp, vertical = 2.dp),675 ) {676 Text("TOTP", color = peachColors.TotpTag, style = PeachType.sortPill)677 }678 }679 if ((entry.breach ?: 0) > 0) {680 Box(Modifier.size(6.dp).clip(CircleShape).background(peachColors.Breach))681 }682 }683}684685686687688689690691692@Composable693private fun PeachPullToRefresh(694 refreshing: Boolean,695 onRefresh: () -> Unit,696 modifier: Modifier = Modifier,697 content: @Composable () -> Unit,698) {699 val density = LocalDensity.current700 val triggerPx = with(density) { 64.dp.toPx() }701 val maxPx = with(density) { 96.dp.toPx() }702 var pull by remember { mutableStateOf(0f) }703704 val connection = remember(refreshing) {705 object : NestedScrollConnection {706 override fun onPreScroll(available: Offset, source: NestedScrollSource): Offset {707 708 if (available.y < 0f && pull > 0f) {709 val used = (-available.y).coerceAtMost(pull)710 pull -= used711 return Offset(0f, -used)712 }713 return Offset.Zero714 }715716 override fun onPostScroll(consumed: Offset, available: Offset, source: NestedScrollSource): Offset {717 718 if (!refreshing && source == NestedScrollSource.UserInput && available.y > 0f) {719 pull = (pull + available.y * 0.5f).coerceAtMost(maxPx)720 return Offset(0f, available.y)721 }722 return Offset.Zero723 }724725 override suspend fun onPreFling(available: Velocity): Velocity {726 if (!refreshing && pull >= triggerPx) onRefresh()727 728 729 730 731 732 733 734 animate(pull, 0f) { v, _ -> pull = v }735 return Velocity.Zero736 }737 }738 }739740 Box(modifier.nestedScroll(connection)) {741 content()742 PullSpinner(pull = pull, triggerPx = triggerPx, refreshing = refreshing, modifier = Modifier.align(Alignment.TopCenter))743 }744}745746@Composable747private fun PullSpinner(pull: Float, triggerPx: Float, refreshing: Boolean, modifier: Modifier = Modifier) {748 if (pull <= 0.5f && !refreshing) return749 val accent = LocalPeachAccent.current750 val progress = (pull / triggerPx).coerceIn(0f, 1f)751 val rotation = if (refreshing) {752 val spin = rememberInfiniteTransition(label = "pull-spin")753 spin.animateFloat(754 initialValue = 0f, targetValue = 360f,755 animationSpec = infiniteRepeatable(tween(820, easing = LinearEasing)),756 label = "pull-rot",757 ).value758 } else {759 progress * 270f760 }761 val sweep = if (refreshing) 300f else 8f + progress * 292f762 Box(763 modifier.padding(top = 6.dp).graphicsLayer { translationY = pull * 0.5f },764 contentAlignment = Alignment.Center,765 ) {766 Canvas(Modifier.size(22.dp)) {767 rotate(rotation) {768 drawArc(769 color = accent.copy(alpha = if (refreshing) 1f else progress),770 startAngle = 0f,771 sweepAngle = sweep,772 useCenter = false,773 style = Stroke(width = 2.dp.toPx(), cap = StrokeCap.Round),774 )775 }776 }777 }778}779780@Composable781private fun EmptyState() {782 PeachEmptyOrEndState(783 message = "No entries yet",784 detail = "add your first entry with +",785 modifier = Modifier.padding(top = 80.dp),786 )787}788789790791792793internal fun entryTapDestination(entry: VaultEntry): PeachNav =794 if (entry.isNote()) PeachNav.NotesMode(entry.id) else PeachNav.EntryDetail(entry.id)795796private fun VaultEntry.domainKeyOrUser(): String {797 val raw = url798 if (!raw.isNullOrBlank()) {799 val cleaned = raw.replace(Regex("^https?://"), "").removePrefix("www.").substringBefore('/')800 if (cleaned.isNotEmpty()) return cleaned801 }802 return username ?: email ?: ""803}