VaultListScreen.kt

33.8 KB · raw

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    // C1: non-null when the list was reached by sliding up from an entry's126    // search strip — slide back down to collapse to that entry. Null when127    // the list itself is the deck root (e.g. empty vault, nothing to open).128    onBack: (() -> Unit)? = null,129    initialSearchActive: Boolean = false,130    modifier: Modifier = Modifier,131    vm: VaultListViewModel = hiltViewModel(),132) {133    // Android never prompts to pick an autofill service, so a user who doesn't134    // go hunting in system settings gets a password manager that silently does135    // nothing on other apps' login screens. Offered once, on the first look at136    // an unlocked vault — the point where autofill is obviously the next thing137    // they'd want.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                    // Some OEM builds ship no handler for the direct picker.145                    runCatching { autofillContext.startActivity(AutofillEnablement.fallbackIntent()) }146                }147        }148    }149    val entries by vm.entries.collectAsState()150151    // U1: warm the on-disk favicon cache for every entry domain; rows render152    // initials until the site's own favicon lands. Bounded concurrency, cached153    // and negative-cached domains skip instantly.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    // Pull-to-refresh drives a manual sync round against paired devices.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    // Pulling to refresh used to retract in silence on every path that was not190    // a clean sync, which reads as "sync ran and found nothing" rather than191    // "there is nobody to sync with" or "the peer refused us". Say which — but192    // only for attempts that finish while she is looking, never for the193    // restored outcome of a sync that ended days ago.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                                // Long-press enters multi-select (Material 3 / Android275                                // list convention) and selects the held entry. The276                                // prior behaviour opened a context menu; the menu's277                                // actions (copy password/username/TOTP, edit, trash)278                                // are still reachable through the new entry-detail279                                // surface, so no functionality is dropped — the280                                // gesture is just no longer ambushed by the281                                // swipe-copy detector, which is the bug this282                                // re-ordering also fixes (see VaultListScreen283                                // modifier chain comment near EntryRow).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                                // Sort changes, deletes, and new entries used to snap293                                // rows straight to their new position — animate the294                                // reflow instead.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        } // Box342    }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                // C1: inverse of EntryDetailSearchStrip's slide-up-to-open —414                // slide the list's own search bar down to collapse back to415                // the entry it was opened from.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    // One row, matching the browser's single combined sort+actions bar — the476    // notes-only filter used to get a whole second segmented control the same477    // visual weight as the 4-way sort control; folded into a single toggle478    // icon in the action cluster instead, same as trash/select/health.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            // One icon family, one optical size. These were previously a mix525            // of AutoMirrored.Outlined, Outlined and Rounded, which draw at526            // different stroke weights and visual densities — side by side in a527            // pill they read as unrelated glyphs rather than a set. The528            // extension's toolbar matches because every glyph there is the same529            // weight; this is the same discipline.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            // Settings last: it is the only app-level action in the cluster,545            // and the extension likewise keeps it out of the content actions.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            // Click + long-press live in ONE combinedClickable, not a604            // Modifier.clickable plus a separate detectTapGestures(onLongPress).605            // Those ran two independent tap detectors on the same pointer that606            // raced for the gesture — clickable's detector tended to win, so the607            // separate onLongPress never reliably fired and long-press-to-multi-608            // select "did nothing" on a real device (re-reported ~5x; an emulator609            // input-injection "fix" once gave a false green). combinedClickable610            // arbitrates click vs long-press correctly, keeps the interactionSource611            // that drives the pressed row background, and coexists with the612            // horizontal swipe-to-copy detector below.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}684685/**686 * Lightweight pull-to-refresh: overscrolling the top of [content] drags an accent687 * spinner down; releasing past the trigger fires [onRefresh]. Self-contained (no688 * Material pull-refresh dependency) so it matches Peach's bespoke styling and adds689 * no new libraries. The content stays put — only the spinner travels, the common690 * "spinner descends from under the header" pattern.691 */692@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                // Scrolling back up retracts any pull we own before the list moves.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                // Leftover downward drag at the top of the list (list consumed none).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                // Always retract. Parking the spinner at the trigger point and728                // waiting for `refreshing` to go true-then-false left it stuck729                // on screen forever whenever that transition never happened —730                // which is every sync that resolves synchronously, including731                // the no-paired-devices case that goes straight to Offline.732                // The spinner's visibility is driven by `refreshing` on its733                // own, so it does not need the pull offset to stay raised.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}788789/**790 * Tap destination for a vault row. NOTE entries open the full-screen notes791 * editor ([PeachNav.NotesMode]); everything else opens the entry detail.792 */793internal 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}