package com.peachpasswords.ui.vault import androidx.compose.animation.animateColorAsState import androidx.compose.animation.core.LinearEasing import androidx.compose.animation.core.animate import androidx.compose.animation.core.animateFloat import androidx.compose.animation.core.infiniteRepeatable import androidx.compose.animation.core.rememberInfiniteTransition import androidx.compose.animation.core.tween import androidx.compose.foundation.Canvas import androidx.compose.foundation.background import androidx.compose.foundation.border import androidx.compose.foundation.clickable import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.gestures.detectHorizontalDragGestures import androidx.compose.foundation.gestures.detectVerticalDragGestures import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.interaction.collectIsPressedAsState import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.statusBarsPadding import androidx.compose.foundation.layout.navigationBarsPadding import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.text.BasicTextField import androidx.compose.material.icons.Icons import androidx.compose.material.icons.outlined.Delete import androidx.compose.material.icons.outlined.Description import androidx.compose.material.icons.outlined.Settings import androidx.compose.material.icons.rounded.Add import androidx.compose.material.icons.rounded.Close import androidx.compose.material.icons.rounded.Search import androidx.compose.material.icons.rounded.Settings import androidx.compose.material3.SmallFloatingActionButton import androidx.compose.material.icons.outlined.Lock import androidx.compose.material3.FloatingActionButton import androidx.compose.material3.Icon import androidx.compose.material3.TextButton import androidx.compose.material3.Snackbar import androidx.compose.material3.SnackbarDuration import androidx.compose.material3.SnackbarHost import androidx.compose.material3.SnackbarHostState import androidx.compose.material3.SnackbarResult import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.drawBehind import androidx.compose.ui.geometry.Offset import androidx.compose.ui.geometry.Size import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.graphics.StrokeCap import androidx.compose.ui.graphics.drawscope.Stroke import androidx.compose.ui.graphics.drawscope.rotate import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.input.nestedscroll.NestedScrollConnection import androidx.compose.ui.input.nestedscroll.NestedScrollSource import androidx.compose.ui.input.nestedscroll.nestedScroll import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.input.pointer.positionChange import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.text.TextStyle import androidx.compose.ui.unit.Velocity import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.hilt.navigation.compose.hiltViewModel import androidx.compose.runtime.collectAsState import com.peachpasswords.license.PendingProNotice import com.peachpasswords.license.proNoticeText import com.peachpasswords.haptics.PeachHaptics import com.peachpasswords.sync.SyncIndicator import com.peachpasswords.sync.SyncOutcomeReason import com.peachpasswords.sync.SyncOutcomeSummary import com.peachpasswords.sync.describeSyncOutcome import com.peachpasswords.sync.syncIndicator import com.peachpasswords.sync.syncIndicatorLabel import com.peachpasswords.ui.sync.SyncViewModel import com.peachpasswords.autofill.AutofillEnablement import com.peachpasswords.ui.PeachNav import com.peachpasswords.shared.extractDomain import com.peachpasswords.ui.components.LocalFavicons import com.peachpasswords.ui.components.LocalFaviconsEnabled import com.peachpasswords.ui.components.PeachEmptyOrEndState import com.peachpasswords.ui.components.PeachFilterPills import com.peachpasswords.ui.components.PeachSegment import com.peachpasswords.ui.components.SyncIndicatorDot import com.peachpasswords.ui.components.FaviconInitials import com.peachpasswords.ui.theme.LocalPeachAccent import com.peachpasswords.ui.theme.PeachSpacing import com.peachpasswords.ui.theme.peachColors import com.peachpasswords.ui.theme.PeachType import com.peachpasswords.ui.theme.ShellBackground import com.peachpasswords.vault.VaultEntry import com.peachpasswords.vault.hasTOTP import com.peachpasswords.vault.isNote import kotlin.math.abs @Composable fun VaultListScreen( onNavigate: (PeachNav) -> Unit, // C1: non-null when the list was reached by sliding up from an entry's // search strip — slide back down to collapse to that entry. Null when // the list itself is the deck root (e.g. empty vault, nothing to open). onBack: (() -> Unit)? = null, initialSearchActive: Boolean = false, modifier: Modifier = Modifier, vm: VaultListViewModel = hiltViewModel(), ) { // Android never prompts to pick an autofill service, so a user who doesn't // go hunting in system settings gets a password manager that silently does // nothing on other apps' login screens. Offered once, on the first look at // an unlocked vault — the point where autofill is obviously the next thing // they'd want. val autofillContext = LocalContext.current LaunchedEffect(Unit) { if (AutofillEnablement.shouldOffer(autofillContext)) { AutofillEnablement.markAsked(autofillContext) runCatching { autofillContext.startActivity(AutofillEnablement.requestIntent(autofillContext)) } .onFailure { // Some OEM builds ship no handler for the direct picker. runCatching { autofillContext.startActivity(AutofillEnablement.fallbackIntent()) } } } } val entries by vm.entries.collectAsState() // U1: warm the on-disk favicon cache for every entry domain; rows render // initials until the site's own favicon lands. Bounded concurrency, cached // and negative-cached domains skip instantly. val favicons = LocalFavicons.current val faviconsEnabled = LocalFaviconsEnabled.current LaunchedEffect(entries, faviconsEnabled) { if (faviconsEnabled) { favicons?.prefetch(entries.mapNotNull { it.url?.let(::extractDomain) }) } } val sort by vm.sort.collectAsState() val search by vm.search.collectAsState() val total by vm.totalCount.collectAsState() val flashId by vm.flashId.collectAsState() val allTags by vm.allTags.collectAsState() val activeTagFilter by vm.activeTagFilter.collectAsState() val noteOnly by vm.noteOnly.collectAsState() val isMultiSelect by vm.isMultiSelectMode.collectAsState() val selectedIds by vm.selectedIds.collectAsState() val undoEvent by vm.undoEvent.collectAsState() val proNotice by vm.proNotice.collectAsState() val snackbarHostState = remember { SnackbarHostState() } // Pull-to-refresh drives a manual sync round against paired devices. val syncVm: SyncViewModel = hiltViewModel() val syncStatus by syncVm.status.collectAsState() val pairedDevices by syncVm.pairedDevices.collectAsState() LaunchedEffect(undoEvent) { val ev = undoEvent ?: return@LaunchedEffect vm.consumeUndoEvent() val result = snackbarHostState.showSnackbar( message = ev.message, actionLabel = "UNDO", duration = SnackbarDuration.Short, ) if (result == SnackbarResult.ActionPerformed) vm.restoreEntries(ev.ids) } // Pulling to refresh used to retract in silence on every path that was not // a clean sync, which reads as "sync ran and found nothing" rather than // "there is nobody to sync with" or "the peer refused us". Say which — but // only for attempts that finish while she is looking, never for the // restored outcome of a sync that ended days ago. val openedAt = remember { System.currentTimeMillis() } LaunchedEffect(syncStatus.finishedAt) { val finishedAt = syncStatus.finishedAt ?: return@LaunchedEffect if (finishedAt < openedAt) return@LaunchedEffect val reason = syncStatus.reason ?: return@LaunchedEffect if (!shouldShowSyncOutcomeSnackbar(reason)) return@LaunchedEffect snackbarHostState.showSnackbar( message = if (reason == SyncOutcomeReason.NO_PAIRED_DEVICES) { "No devices paired yet — add one in Settings › Sync" } else { describeSyncOutcome(syncStatus) }, duration = SnackbarDuration.Short, ) } ShellBackground(modifier) { Box(Modifier.fillMaxSize()) { Column(Modifier.fillMaxSize()) { if (isMultiSelect) { MultiSelectTopBar( count = selectedIds.size, onDelete = { vm.batchSoftDelete() }, onCancel = { vm.exitMultiSelect() }, ) } ProNoticeBanner( notice = proNotice, onLearnMore = { vm.dismissProNotice() onNavigate(PeachNav.Settings) }, onDismiss = vm::dismissProNotice, ) SearchBar( value = search, onChange = vm::setSearch, count = if (search.isNotBlank()) "${entries.size}/$total" else pluralEntries(total), onCollapse = onBack, topInset = !isMultiSelect, requestFocus = initialSearchActive, syncStatus = syncStatus, pairedCount = pairedDevices.size, ) if (allTags.isNotEmpty()) { TagFilterStrip( allTags = allTags, activeTagFilter = activeTagFilter, onSelect = vm::setTagFilter, ) } SortBar( active = sort, noteOnly = noteOnly, onSettings = { onNavigate(PeachNav.Settings) }, onSort = vm::setSort, onToggleNote = vm::toggleNoteFilter, onTrash = { onNavigate(PeachNav.Trash) }, ) Box(Modifier.fillMaxWidth().weight(1f)) { PeachPullToRefresh( refreshing = syncStatus.syncing, onRefresh = { syncVm.syncNow() }, modifier = Modifier.fillMaxSize(), ) { LazyColumn(Modifier.fillMaxSize()) { if (entries.isEmpty()) { item { EmptyState() } } items(entries, key = { it.id }) { entry -> EntryRow( entry = entry, flashing = flashId == entry.id, canSwipeCopy = vm.canSwipeCopy(entry) && !isMultiSelect, multiSelect = isMultiSelect, selected = entry.id in selectedIds, onTap = { if (isMultiSelect) vm.toggleSelected(entry.id) else onNavigate(entryTapDestination(entry)) }, // Long-press enters multi-select (Material 3 / Android // list convention) and selects the held entry. The // prior behaviour opened a context menu; the menu's // actions (copy password/username/TOTP, edit, trash) // are still reachable through the new entry-detail // surface, so no functionality is dropped — the // gesture is just no longer ambushed by the // swipe-copy detector, which is the bug this // re-ordering also fixes (see VaultListScreen // modifier chain comment near EntryRow). onLongPress = { if (isMultiSelect) { vm.toggleSelected(entry.id) } else { vm.enterMultiSelect(entry.id) } }, onSwipeCopy = { vm.copyPassword(entry) }, // Sort changes, deletes, and new entries used to snap // rows straight to their new position — animate the // reflow instead. modifier = Modifier.animateItem(), ) } item { Spacer(Modifier.height(88.dp)) } } } Column( modifier = Modifier.align(Alignment.BottomEnd) .padding(PeachSpacing.lg) .navigationBarsPadding(), horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(PeachSpacing.md), ) { SmallFloatingActionButton( onClick = vm::lock, containerColor = peachColors.S1, contentColor = LocalPeachAccent.current, ) { Icon(Icons.Outlined.Lock, contentDescription = "Lock vault") } FloatingActionButton( onClick = { if (noteOnly) vm.createNote { id -> onNavigate(PeachNav.NotesMode(id)) } else onNavigate(PeachNav.EntryEdit(null)) }, containerColor = LocalPeachAccent.current, contentColor = peachColors.Bg, ) { Icon( Icons.Rounded.Add, contentDescription = if (noteOnly) "new note" else "new entry", modifier = Modifier.size(28.dp), ) } } } } SnackbarHost( hostState = snackbarHostState, modifier = Modifier.align(Alignment.BottomCenter).navigationBarsPadding(), ) { data -> Snackbar( containerColor = peachColors.SurfaceElevated, contentColor = peachColors.T0, actionContentColor = LocalPeachAccent.current, snackbarData = data, ) } } // Box } } @Composable private fun ProNoticeBanner( notice: PendingProNotice?, onLearnMore: () -> Unit, onDismiss: () -> Unit, ) { if (notice == null) return Row( modifier = Modifier .fillMaxWidth() .background(LocalPeachAccent.current.copy(alpha = 0.10f)) .border(0.5.dp, LocalPeachAccent.current.copy(alpha = 0.24f)) .padding(start = 16.dp, top = 4.dp, end = 8.dp, bottom = 4.dp), verticalAlignment = Alignment.CenterVertically, ) { Text( text = proNoticeText(notice), color = peachColors.T1, style = PeachType.labelMono.copy(fontSize = 10.sp), modifier = Modifier.weight(1f), ) TextButton(onClick = onLearnMore) { Text("Learn more", color = LocalPeachAccent.current, style = PeachType.labelMono.copy(fontSize = 10.sp)) } Icon( Icons.Rounded.Close, contentDescription = "Dismiss Pro notice", tint = peachColors.T2, modifier = Modifier .size(28.dp) .clip(CircleShape) .clickable { onDismiss() } .padding(7.dp), ) } } internal fun shouldShowSyncOutcomeSnackbar(reason: SyncOutcomeReason): Boolean = reason != SyncOutcomeReason.COMPLETED_CHANGES && reason != SyncOutcomeReason.COMPLETED_NO_CHANGES private fun pluralEntries(total: Int): String = if (total == 1) "1 entry" else "$total entries" @Composable private fun SearchBar( value: String, onChange: (String) -> Unit, count: String, onCollapse: (() -> Unit)? = null, topInset: Boolean = false, requestFocus: Boolean = false, syncStatus: SyncOutcomeSummary? = null, pairedCount: Int = 0, ) { val accent = LocalPeachAccent.current val focusRequester = remember { FocusRequester() } LaunchedEffect(requestFocus) { if (requestFocus) focusRequester.requestFocus() } Column(Modifier.fillMaxWidth().then(if (topInset) Modifier.statusBarsPadding() else Modifier)) { Row( Modifier .fillMaxWidth() .padding(horizontal = PeachSpacing.md, vertical = PeachSpacing.sm) .clip(RoundedCornerShape(12.dp)) .background(peachColors.S2) .padding(horizontal = PeachSpacing.md, vertical = PeachSpacing.sm) // C1: inverse of EntryDetailSearchStrip's slide-up-to-open — // slide the list's own search bar down to collapse back to // the entry it was opened from. .then( if (onCollapse != null) { Modifier.pointerInput(onCollapse) { var collapsed = false detectVerticalDragGestures( onDragEnd = { collapsed = false }, onDragCancel = { collapsed = false }, ) { _, dragAmount -> if (!collapsed && dragAmount > 4f) { collapsed = true onCollapse() } } } } else { Modifier }, ), verticalAlignment = Alignment.CenterVertically, ) { Icon(Icons.Rounded.Search, contentDescription = null, tint = peachColors.T2, modifier = Modifier.size(14.dp)) Spacer(Modifier.width(PeachSpacing.sm)) Box(Modifier.weight(1f)) { if (value.isEmpty()) { Text("search vault…", color = peachColors.T2, style = PeachType.searchInput) } BasicTextField( value = value, onValueChange = onChange, singleLine = true, textStyle = PeachType.searchInput.copy(color = peachColors.T0), cursorBrush = SolidColor(accent), modifier = Modifier.fillMaxWidth() .focusRequester(focusRequester), ) } if (syncStatus != null) { val indicator = syncIndicator(syncStatus, pairedCount) SyncIndicatorDot( indicator = indicator, label = syncIndicatorLabel(indicator, syncStatus), ) Spacer(Modifier.width(PeachSpacing.sm)) } Text(count, color = peachColors.T2, style = PeachType.tinyMono) } } } @Composable private fun SortBar( active: SortOrder, noteOnly: Boolean, onSettings: () -> Unit, onSort: (SortOrder) -> Unit, onToggleNote: () -> Unit, onTrash: () -> Unit, ) { val hairline = peachColors.T3 // One row, matching the browser's single combined sort+actions bar — the // notes-only filter used to get a whole second segmented control the same // visual weight as the 4-way sort control; folded into a single toggle // icon in the action cluster instead, same as trash/select/health. Row( modifier = Modifier .fillMaxWidth() .drawBehind { drawRect( color = hairline, topLeft = Offset(0f, size.height - 1.dp.toPx()), size = Size(size.width, 1.dp.toPx()), ) } .padding(horizontal = PeachSpacing.md, vertical = PeachSpacing.sm), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(PeachSpacing.sm), ) { PeachFilterPills( segments = SortOrder.entries.map { PeachSegment(it.name, it.label) }, selectedKey = active.name, onSelected = { key -> SortOrder.entries.firstOrNull { it.name == key }?.let(onSort) }, modifier = Modifier.weight(1f), ) ActionCluster(noteOnly, onSettings, onToggleNote, onTrash) } } @Composable private fun ActionCluster( noteOnly: Boolean, onSettings: () -> Unit, onToggleNote: () -> Unit, onTrash: () -> Unit, ) { val accent = LocalPeachAccent.current Surface( color = peachColors.S1, contentColor = peachColors.T1, shape = RoundedCornerShape(999.dp), tonalElevation = 1.dp, ) { Row( modifier = Modifier.padding(PeachSpacing.xs), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(PeachSpacing.xs), ) { // One icon family, one optical size. These were previously a mix // of AutoMirrored.Outlined, Outlined and Rounded, which draw at // different stroke weights and visual densities — side by side in a // pill they read as unrelated glyphs rather than a set. The // extension's toolbar matches because every glyph there is the same // weight; this is the same discipline. ClusterIcon( icon = { Icon( Icons.Outlined.Description, contentDescription = "Notes only", tint = if (noteOnly) accent else peachColors.T1, ) }, onClick = onToggleNote, ) ClusterIcon( icon = { Icon(Icons.Outlined.Delete, contentDescription = "trash") }, onClick = onTrash, ) // Settings last: it is the only app-level action in the cluster, // and the extension likewise keeps it out of the content actions. ClusterIcon( icon = { Icon(Icons.Outlined.Settings, contentDescription = "settings") }, onClick = onSettings, ) } } } @Composable private fun ClusterIcon(icon: @Composable () -> Unit, onClick: () -> Unit) { Box( modifier = Modifier .size(34.dp) .clip(CircleShape) .clickable(onClick = onClick) .padding(7.dp), contentAlignment = Alignment.Center, ) { androidx.compose.runtime.CompositionLocalProvider( androidx.compose.material3.LocalContentColor provides peachColors.T1, content = icon, ) } } @OptIn(ExperimentalFoundationApi::class) @Composable private fun EntryRow( entry: VaultEntry, flashing: Boolean, canSwipeCopy: Boolean, multiSelect: Boolean, selected: Boolean, onTap: () -> Unit, onLongPress: () -> Unit, onSwipeCopy: () -> Unit, modifier: Modifier = Modifier, ) { val context = LocalContext.current val accent = LocalPeachAccent.current val interaction = remember { MutableInteractionSource() } val pressed by interaction.collectIsPressedAsState() val rowBg by animateColorAsState( targetValue = when { flashing -> accent.copy(alpha = 0.32f) pressed -> peachColors.T4 else -> Color.Transparent }, label = "row-bg", ) val meta = remember(entry) { entry.domainKeyOrUser() } Row( modifier = modifier .fillMaxWidth() .clip(RoundedCornerShape(7.dp)) .background(rowBg) // Click + long-press live in ONE combinedClickable, not a // Modifier.clickable plus a separate detectTapGestures(onLongPress). // Those ran two independent tap detectors on the same pointer that // raced for the gesture — clickable's detector tended to win, so the // separate onLongPress never reliably fired and long-press-to-multi- // select "did nothing" on a real device (re-reported ~5x; an emulator // input-injection "fix" once gave a false green). combinedClickable // arbitrates click vs long-press correctly, keeps the interactionSource // that drives the pressed row background, and coexists with the // horizontal swipe-to-copy detector below. .combinedClickable( interactionSource = interaction, indication = null, onClick = onTap, onLongClickLabel = "Select", onLongClick = { PeachHaptics.onLongPress(context) onLongPress() }, ) .pointerInput(entry.id, canSwipeCopy) { if (!canSwipeCopy) return@pointerInput var dx = 0f var dy = 0f var startTime = 0L detectHorizontalDragGestures( onDragStart = { dx = 0f; dy = 0f; startTime = System.currentTimeMillis() }, onDragEnd = { val dt = System.currentTimeMillis() - startTime if (dx > 80.dp.toPx() && abs(dy) < 30.dp.toPx() && dt < 400) { PeachHaptics.onSwipeCopy(context) onSwipeCopy() } }, onHorizontalDrag = { change, amount -> dx += amount; dy += change.positionChange().y }, ) } .padding(horizontal = 16.dp, vertical = 7.dp), horizontalArrangement = Arrangement.spacedBy(10.dp), verticalAlignment = Alignment.CenterVertically, ) { if (multiSelect) { MultiSelectCheckbox(checked = selected) } FaviconInitials(source = entry.url ?: entry.title, size = 36.dp) Column(Modifier.weight(1f)) { Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) { Text( text = entry.title, color = peachColors.T0, style = PeachType.entryNameList, maxLines = 1, modifier = Modifier.weight(1f, fill = false), ) entry.tags?.takeIf { it.isNotEmpty() }?.let { tags -> EntryRowTags(tags) } } if (meta.isNotEmpty()) { Text( text = meta, color = peachColors.T2, style = TextStyle(fontFamily = PeachType.Mono, fontSize = 9.sp), maxLines = 1, ) } } if (entry.hasTOTP()) { Box( Modifier .clip(RoundedCornerShape(4.dp)) .background(peachColors.TotpTag.copy(alpha = 0.14f)) .border(0.5.dp, peachColors.TotpTag.copy(alpha = 0.30f), RoundedCornerShape(4.dp)) .padding(horizontal = 6.dp, vertical = 2.dp), ) { Text("TOTP", color = peachColors.TotpTag, style = PeachType.sortPill) } } if ((entry.breach ?: 0) > 0) { Box(Modifier.size(6.dp).clip(CircleShape).background(peachColors.Breach)) } } } /** * Lightweight pull-to-refresh: overscrolling the top of [content] drags an accent * spinner down; releasing past the trigger fires [onRefresh]. Self-contained (no * Material pull-refresh dependency) so it matches Peach's bespoke styling and adds * no new libraries. The content stays put — only the spinner travels, the common * "spinner descends from under the header" pattern. */ @Composable private fun PeachPullToRefresh( refreshing: Boolean, onRefresh: () -> Unit, modifier: Modifier = Modifier, content: @Composable () -> Unit, ) { val density = LocalDensity.current val triggerPx = with(density) { 64.dp.toPx() } val maxPx = with(density) { 96.dp.toPx() } var pull by remember { mutableStateOf(0f) } val connection = remember(refreshing) { object : NestedScrollConnection { override fun onPreScroll(available: Offset, source: NestedScrollSource): Offset { // Scrolling back up retracts any pull we own before the list moves. if (available.y < 0f && pull > 0f) { val used = (-available.y).coerceAtMost(pull) pull -= used return Offset(0f, -used) } return Offset.Zero } override fun onPostScroll(consumed: Offset, available: Offset, source: NestedScrollSource): Offset { // Leftover downward drag at the top of the list (list consumed none). if (!refreshing && source == NestedScrollSource.UserInput && available.y > 0f) { pull = (pull + available.y * 0.5f).coerceAtMost(maxPx) return Offset(0f, available.y) } return Offset.Zero } override suspend fun onPreFling(available: Velocity): Velocity { if (!refreshing && pull >= triggerPx) onRefresh() // Always retract. Parking the spinner at the trigger point and // waiting for `refreshing` to go true-then-false left it stuck // on screen forever whenever that transition never happened — // which is every sync that resolves synchronously, including // the no-paired-devices case that goes straight to Offline. // The spinner's visibility is driven by `refreshing` on its // own, so it does not need the pull offset to stay raised. animate(pull, 0f) { v, _ -> pull = v } return Velocity.Zero } } } Box(modifier.nestedScroll(connection)) { content() PullSpinner(pull = pull, triggerPx = triggerPx, refreshing = refreshing, modifier = Modifier.align(Alignment.TopCenter)) } } @Composable private fun PullSpinner(pull: Float, triggerPx: Float, refreshing: Boolean, modifier: Modifier = Modifier) { if (pull <= 0.5f && !refreshing) return val accent = LocalPeachAccent.current val progress = (pull / triggerPx).coerceIn(0f, 1f) val rotation = if (refreshing) { val spin = rememberInfiniteTransition(label = "pull-spin") spin.animateFloat( initialValue = 0f, targetValue = 360f, animationSpec = infiniteRepeatable(tween(820, easing = LinearEasing)), label = "pull-rot", ).value } else { progress * 270f } val sweep = if (refreshing) 300f else 8f + progress * 292f Box( modifier.padding(top = 6.dp).graphicsLayer { translationY = pull * 0.5f }, contentAlignment = Alignment.Center, ) { Canvas(Modifier.size(22.dp)) { rotate(rotation) { drawArc( color = accent.copy(alpha = if (refreshing) 1f else progress), startAngle = 0f, sweepAngle = sweep, useCenter = false, style = Stroke(width = 2.dp.toPx(), cap = StrokeCap.Round), ) } } } } @Composable private fun EmptyState() { PeachEmptyOrEndState( message = "No entries yet", detail = "add your first entry with +", modifier = Modifier.padding(top = 80.dp), ) } /** * Tap destination for a vault row. NOTE entries open the full-screen notes * editor ([PeachNav.NotesMode]); everything else opens the entry detail. */ internal fun entryTapDestination(entry: VaultEntry): PeachNav = if (entry.isNote()) PeachNav.NotesMode(entry.id) else PeachNav.EntryDetail(entry.id) private fun VaultEntry.domainKeyOrUser(): String { val raw = url if (!raw.isNullOrBlank()) { val cleaned = raw.replace(Regex("^https?://"), "").removePrefix("www.").substringBefore('/') if (cleaned.isNotEmpty()) return cleaned } return username ?: email ?: "" }