ee76db7f27e3

feat: Add long-press selection to vault and Notes (v2.7.7)

time
2026-07-27 22:28 UTC · 2 months ago
machine
koh2
parent
916249d84985
7 files app/build.gradle+2 −2app/src/main/java/com/peachpasswords/ui/vault/MultiSelectUi.kt+2 −1app/src/main/java/com/peachpasswords/ui/vault/NotesModeScreen.kt+94 −30app/src/main/java/com/peachpasswords/ui/vault/NotesModeViewModel.kt+34 −0app/src/main/java/com/peachpasswords/ui/vault/VaultListScreen.kt+6 −15app/src/main/java/com/peachpasswords/ui/vault/VaultListViewModel.kt+0 −5app/src/test/java/com/peachpasswords/ui/vault/NotesModeTest.kt+35 −0
app/build.gradle+2 −2
@@ -16,8 +16,8 @@1616         applicationId "com.peachpasswords"1717         minSdk 261818         targetSdk 3519-        versionCode 24013320-        versionName "2.7.6"19+        versionCode 24013420+        versionName "2.7.7"2121 2222         testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"2323         vectorDrawables {

view file

app/src/main/java/com/peachpasswords/ui/vault/MultiSelectUi.kt+2 −1
@@ -75,11 +75,12 @@7575     count: Int,7676     onDelete: () -> Unit,7777     onCancel: () -> Unit,78+    includeStatusBarPadding: Boolean = true,7879 ) {7980     Row(8081         modifier = Modifier8182             .fillMaxWidth()82-            .statusBarsPadding()83+            .then(if (includeStatusBarPadding) Modifier.statusBarsPadding() else Modifier)8384             .height(56.dp)8485             .padding(horizontal = 16.dp),8586         verticalAlignment = Alignment.CenterVertically,

view file

app/src/main/java/com/peachpasswords/ui/vault/NotesModeScreen.kt+94 −30
@@ -1,9 +1,12 @@11 package com.peachpasswords.ui.vault22 3+import androidx.activity.compose.BackHandler34 import androidx.compose.animation.core.animateFloatAsState45 import androidx.compose.animation.core.tween56 import androidx.compose.foundation.background67 import androidx.compose.foundation.clickable8+import androidx.compose.foundation.combinedClickable9+import androidx.compose.foundation.ExperimentalFoundationApi710 import androidx.compose.foundation.layout.Arrangement811 import androidx.compose.foundation.layout.Box912 import androidx.compose.foundation.layout.Column@@ -41,6 +44,7 @@4144 import androidx.compose.ui.draw.alpha4245 import androidx.compose.ui.draw.clip4346 import androidx.compose.ui.graphics.SolidColor47+import androidx.compose.ui.platform.LocalContext4448 import androidx.compose.ui.text.TextStyle4549 import androidx.compose.ui.text.TextRange4650 import androidx.compose.ui.text.font.FontStyle@@ -51,6 +55,7 @@5155 import androidx.compose.ui.unit.sp5256 import androidx.compose.runtime.collectAsState5357 import androidx.hilt.navigation.compose.hiltViewModel58+import com.peachpasswords.haptics.PeachHaptics5459 import com.peachpasswords.ui.components.PeachEmptyOrEndState5560 import com.peachpasswords.ui.components.PeachTopBar5661 import com.peachpasswords.ui.components.SecureFlagEffect@@ -80,6 +85,8 @@8085     val entry by vm.entry.collectAsState()8186     val notes by vm.notes.collectAsState()8287     val activeNoteId by vm.activeNoteId.collectAsState()88+    val isMultiSelect by vm.isMultiSelectMode.collectAsState()89+    val selectedIds by vm.selectedIds.collectAsState()8390     val savedPulse by vm.savedPulse.collectAsState()8491     val accent = LocalPeachAccent.current8592 @@ -105,6 +112,12 @@105112             onBack = onBack,106113             onOpenNote = vm::openNote,107114             onNewNote = vm::createNote,115+            isMultiSelect = isMultiSelect,116+            selectedIds = selectedIds,117+            onEnterMultiSelect = vm::enterMultiSelect,118+            onToggleSelected = vm::toggleSelected,119+            onDeleteSelected = vm::batchDelete,120+            onCancelMultiSelect = vm::exitMultiSelect,108121         )109122         return110123     }@@ -252,11 +265,18 @@252265     onBack: () -> Unit,253266     onOpenNote: (String) -> Unit,254267     onNewNote: () -> Unit,268+    isMultiSelect: Boolean,269+    selectedIds: Set<String>,270+    onEnterMultiSelect: (String) -> Unit,271+    onToggleSelected: (String) -> Unit,272+    onDeleteSelected: () -> Unit,273+    onCancelMultiSelect: () -> Unit,255274 ) {256275     var searchVisible by remember { mutableStateOf(false) }257276     var query by remember { mutableStateOf("") }258277     val filtered = remember(notes, query) { filterNotesForToc(notes, query) }259278     val accent = LocalPeachAccent.current279+    BackHandler(enabled = isMultiSelect, onBack = onCancelMultiSelect)260280 261281     ShellBackground {262282         Column(@@ -265,7 +285,14 @@265285                 .statusBarsPadding()266286                 .padding(horizontal = PeachSpacing.lg, vertical = PeachSpacing.md),267287         ) {268-            if (searchVisible) {288+            if (isMultiSelect) {289+                MultiSelectTopBar(290+                    count = selectedIds.size,291+                    onDelete = onDeleteSelected,292+                    onCancel = onCancelMultiSelect,293+                    includeStatusBarPadding = false,294+                )295+            } else if (searchVisible) {269296                 Surface(270297                     modifier = Modifier.fillMaxWidth(),271298                     color = peachColors.S1,@@ -353,49 +380,86 @@353380                     }354381                 } else {355382                     items(filtered, key = { it.id }) { note ->356-                        NoteTocRow(note = note, onClick = { onOpenNote(note.id) })383+                        NoteTocRow(384+                            note = note,385+                            multiSelect = isMultiSelect,386+                            selected = note.id in selectedIds,387+                            onClick = {388+                                if (isMultiSelect) onToggleSelected(note.id)389+                                else onOpenNote(note.id)390+                            },391+                            onLongClick = {392+                                if (isMultiSelect) onToggleSelected(note.id)393+                                else onEnterMultiSelect(note.id)394+                            },395+                        )357396                     }358397                 }359398             }360399 361-            Row(362-                Modifier.fillMaxWidth().padding(vertical = PeachSpacing.md)363-                    .clip(RoundedCornerShape(8.dp))364-                    .background(peachColors.S2)365-                    .clickable(onClick = onNewNote)366-                    .padding(horizontal = 14.dp, vertical = 12.dp),367-                verticalAlignment = Alignment.CenterVertically,368-                horizontalArrangement = Arrangement.spacedBy(10.dp),369-            ) {370-                Icon(Icons.Outlined.Add, contentDescription = null, tint = accent, modifier = Modifier.size(18.dp))371-                Text("+ new note", color = peachColors.T0, style = PeachType.bodyMono)400+            if (!isMultiSelect) {401+                Row(402+                    Modifier.fillMaxWidth().padding(vertical = PeachSpacing.md)403+                        .clip(RoundedCornerShape(8.dp))404+                        .background(peachColors.S2)405+                        .clickable(onClick = onNewNote)406+                        .padding(horizontal = 14.dp, vertical = 12.dp),407+                    verticalAlignment = Alignment.CenterVertically,408+                    horizontalArrangement = Arrangement.spacedBy(10.dp),409+                ) {410+                    Icon(Icons.Outlined.Add, contentDescription = null, tint = accent, modifier = Modifier.size(18.dp))411+                    Text("+ new note", color = peachColors.T0, style = PeachType.bodyMono)412+                }372413             }373414         }374415     }375416 }376417 418+@OptIn(ExperimentalFoundationApi::class)377419 @Composable378-private fun NoteTocRow(note: VaultEntry, onClick: () -> Unit) {379-    Column(420+private fun NoteTocRow(421+    note: VaultEntry,422+    multiSelect: Boolean,423+    selected: Boolean,424+    onClick: () -> Unit,425+    onLongClick: () -> Unit,426+) {427+    val context = LocalContext.current428+    val accent = LocalPeachAccent.current429+    Row(380430         Modifier.fillMaxWidth()381431             .clip(RoundedCornerShape(8.dp))382-            .background(peachColors.S1)383-            .clickable(onClick = onClick)432+            .background(if (selected) accent.copy(alpha = 0.14f) else peachColors.S1)433+            .combinedClickable(434+                onClick = onClick,435+                onLongClickLabel = "Select note",436+                onLongClick = {437+                    PeachHaptics.onLongPress(context)438+                    onLongClick()439+                },440+            )384441             .padding(horizontal = 14.dp, vertical = 12.dp),385-        verticalArrangement = Arrangement.spacedBy(5.dp),442+        horizontalArrangement = Arrangement.spacedBy(PeachSpacing.sm),443+        verticalAlignment = Alignment.CenterVertically,386444     ) {387-        Text(388-            note.title.ifBlank { "Untitled" },389-            color = peachColors.T0,390-            style = TextStyle(fontFamily = PeachType.Display, fontStyle = FontStyle.Italic, fontSize = 20.sp),391-        )392-        val preview = notePreviewLine(note.notes.orEmpty())393-        Text(394-            preview.ifBlank { "No body text" },395-            color = peachColors.T2,396-            style = PeachType.bodyMono,397-            maxLines = 2,398-        )445+        if (multiSelect) MultiSelectCheckbox(checked = selected)446+        Column(447+            Modifier.weight(1f),448+            verticalArrangement = Arrangement.spacedBy(5.dp),449+        ) {450+            Text(451+                note.title.ifBlank { "Untitled" },452+                color = peachColors.T0,453+                style = TextStyle(fontFamily = PeachType.Display, fontStyle = FontStyle.Italic, fontSize = 20.sp),454+            )455+            val preview = notePreviewLine(note.notes.orEmpty())456+            Text(457+                preview.ifBlank { "No body text" },458+                color = peachColors.T2,459+                style = PeachType.bodyMono,460+                maxLines = 2,461+            )462+        }399463     }400464 }401465 

view file

app/src/main/java/com/peachpasswords/ui/vault/NotesModeViewModel.kt+34 −0
@@ -49,6 +49,12 @@4949 5050     val activeNoteId: StateFlow<String?> = _activeNoteId.asStateFlow()5151 52+    private val _isMultiSelectMode = MutableStateFlow(false)53+    val isMultiSelectMode: StateFlow<Boolean> = _isMultiSelectMode.asStateFlow()54+55+    private val _selectedIds = MutableStateFlow<Set<String>>(emptySet())56+    val selectedIds: StateFlow<Set<String>> = _selectedIds.asStateFlow()57+5258     private val _savedPulse = MutableStateFlow(0L)5359     /** Increments on each successful auto-save so the screen can re-trigger its fade. */5460     val savedPulse: StateFlow<Long> = _savedPulse.asStateFlow()@@ -74,16 +80,19 @@7480 7581     fun openNote(noteId: String) {7682         saveJob?.cancel()83+        exitMultiSelect()7784         _activeNoteId.value = noteId7885     }7986 8087     fun showToc() {8188         saveJob?.cancel()89+        exitMultiSelect()8290         _activeNoteId.value = null8391     }8492 8593     fun createNote() {8694         saveJob?.cancel()95+        exitMultiSelect()8796         viewModelScope.launch {8897             val created = vault.addEntry(8998                 VaultEntry(@@ -115,6 +124,31 @@115124             _activeNoteId.value?.let { runCatching { vault.deleteEntry(it) } }116125             _activeNoteId.value = null117126             onDone()127+        }128+    }129+130+    fun enterMultiSelect(noteId: String) {131+        _isMultiSelectMode.value = true132+        _selectedIds.value = setOf(noteId)133+    }134+135+    fun toggleSelected(noteId: String) {136+        val next = _selectedIds.value.toMutableSet()137+        if (!next.add(noteId)) next.remove(noteId)138+        _selectedIds.value = next139+    }140+141+    fun exitMultiSelect() {142+        _isMultiSelectMode.value = false143+        _selectedIds.value = emptySet()144+    }145+146+    fun batchDelete() {147+        val ids = _selectedIds.value.toList()148+        if (ids.isEmpty()) return149+        viewModelScope.launch {150+            ids.forEach { id -> runCatching { vault.deleteEntry(id) } }151+            exitMultiSelect()118152         }119153     }120154 

view file

app/src/main/java/com/peachpasswords/ui/vault/VaultListScreen.kt+6 −15
@@ -36,7 +36,6 @@3636 import androidx.compose.foundation.shape.RoundedCornerShape3737 import androidx.compose.foundation.text.BasicTextField3838 import androidx.compose.material.icons.Icons39-import androidx.compose.material.icons.outlined.Checklist4039 import androidx.compose.material.icons.outlined.Delete4140 import androidx.compose.material.icons.outlined.Description4241 import androidx.compose.material.icons.outlined.Settings@@ -81,7 +80,6 @@8180 import androidx.compose.ui.input.pointer.positionChange8281 import androidx.compose.ui.platform.LocalContext8382 import androidx.compose.ui.platform.LocalDensity84-import androidx.compose.ui.platform.LocalView8583 import androidx.compose.ui.text.TextStyle8684 import androidx.compose.ui.unit.Velocity8785 import androidx.compose.ui.unit.dp@@ -154,8 +152,6 @@154152     val undoEvent by vm.undoEvent.collectAsState()155153     val proNotice by vm.proNotice.collectAsState()156154     val snackbarHostState = remember { SnackbarHostState() }157-    var menuEntry by remember { mutableStateOf<VaultEntry?>(null) }158-    val view = LocalView.current159155     // Pull-to-refresh drives a manual sync round against paired devices.160156     val syncVm: SyncViewModel = hiltViewModel()161157     val syncStatus by syncVm.status.collectAsState()@@ -241,7 +237,6 @@241237                 onSettings = { onNavigate(PeachNav.Settings) },242238                 onSort = vm::setSort,243239                 onToggleNote = vm::toggleNoteFilter,244-                onSelectMode = vm::enterMultiSelect,245240                 onTrash = { onNavigate(PeachNav.Trash) },246241             )247242             Box(Modifier.fillMaxWidth().weight(1f)) {@@ -276,8 +271,11 @@276271                                 // re-ordering also fixes (see VaultListScreen277272                                 // modifier chain comment near EntryRow).278273                                 onLongPress = {279-                                    if (!isMultiSelect) vm.enterMultiSelect()280-                                    vm.toggleSelected(entry.id)274+                                    if (isMultiSelect) {275+                                        vm.toggleSelected(entry.id)276+                                    } else {277+                                        vm.enterMultiSelect(entry.id)278+                                    }281279                                 },282280                                 onSwipeCopy = { vm.copyPassword(entry) },283281                                 // Sort changes, deletes, and new entries used to snap@@ -460,7 +458,6 @@460458     onSettings: () -> Unit,461459     onSort: (SortOrder) -> Unit,462460     onToggleNote: () -> Unit,463-    onSelectMode: () -> Unit,464461     onTrash: () -> Unit,465462 ) {466463     val hairline = peachColors.T3@@ -494,7 +491,6 @@494491             noteOnly = noteOnly,495492             onSettings = onSettings,496493             onToggleNote = onToggleNote,497-            onSelectMode = onSelectMode,498494             onTrash = onTrash,499495         )500496     }@@ -505,7 +501,6 @@505501     noteOnly: Boolean,506502     onSettings: () -> Unit,507503     onToggleNote: () -> Unit,508-    onSelectMode: () -> Unit,509504     onTrash: () -> Unit,510505 ) {511506     val accent = LocalPeachAccent.current@@ -523,7 +518,7 @@523518             // One icon family, one optical size. These were previously a mix524519             // of AutoMirrored.Outlined, Outlined and Rounded, which draw at525520             // different stroke weights and visual densities — side by side in a526-            // pill they read as four unrelated glyphs rather than a set. The521+            // pill they read as unrelated glyphs rather than a set. The527522             // extension's toolbar matches because every glyph there is the same528523             // weight; this is the same discipline.529524             ClusterIcon(@@ -535,10 +530,6 @@535530                     )536531                 },537532                 onClick = onToggleNote,538-            )539-            ClusterIcon(540-                icon = { Icon(Icons.Outlined.Checklist, contentDescription = "Select entries") },541-                onClick = onSelectMode,542533             )543534             ClusterIcon(544535                 icon = { Icon(Icons.Outlined.Delete, contentDescription = "trash") },

view file

app/src/main/java/com/peachpasswords/ui/vault/VaultListViewModel.kt+0 −5
@@ -95,11 +95,6 @@9595     private val _selectedIds = MutableStateFlow<Set<String>>(emptySet())9696     val selectedIds: StateFlow<Set<String>> = _selectedIds.asStateFlow()9797 98-    fun enterMultiSelect() {99-        _isMultiSelectMode.value = true100-        _selectedIds.value = emptySet()101-    }102-10398     fun enterMultiSelect(id: String) {10499         _isMultiSelectMode.value = true105100         _selectedIds.value = setOf(id)

view file

app/src/test/java/com/peachpasswords/ui/vault/NotesModeTest.kt+35 −0
@@ -21,6 +21,7 @@2121 import org.junit.After2222 import org.junit.Assert.assertEquals2323 import org.junit.Assert.assertNull24+import org.junit.Assert.assertTrue2425 import org.junit.Before2526 import org.junit.Test2627 @@ -205,5 +206,39 @@205206 206207         assertEquals(true, done)207208         assertNull(repo.getEntry(saved.id))209+    }210+211+    @Test fun notes_long_press_selection_starts_with_the_held_note_selected() = runTest(dispatcher) {212+        val repo = VaultRepository(FakeStorage())213+        repo.createVault("correct horse battery")214+        val saved = repo.addEntry(note("select me"))215+        val vm = NotesModeViewModel(repo, SavedStateHandle(mapOf("id" to "")))216+217+        vm.enterMultiSelect(saved.id)218+219+        assertTrue(vm.isMultiSelectMode.value)220+        assertEquals(setOf(saved.id), vm.selectedIds.value)221+        vm.toggleSelected(saved.id)222+        assertEquals(emptySet<String>(), vm.selectedIds.value)223+        vm.exitMultiSelect()224+        assertEquals(false, vm.isMultiSelectMode.value)225+    }226+227+    @Test fun notes_multi_select_moves_every_selected_note_to_trash() = runTest(dispatcher) {228+        val repo = VaultRepository(FakeStorage())229+        repo.createVault("correct horse battery")230+        val first = repo.addEntry(note("first"))231+        val second = repo.addEntry(note("second"))232+        val vm = NotesModeViewModel(repo, SavedStateHandle(mapOf("id" to "")))233+234+        vm.enterMultiSelect(first.id)235+        vm.toggleSelected(second.id)236+        vm.batchDelete()237+        advanceUntilIdle()238+239+        assertNull(repo.getEntry(first.id))240+        assertNull(repo.getEntry(second.id))241+        assertEquals(false, vm.isMultiSelectMode.value)242+        assertEquals(emptySet<String>(), vm.selectedIds.value)208243     }209244 }

view file