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
↓ peach-android-ee76db7f.tar.gzatlas at this save
files changed7 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 −0app/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 {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,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 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 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") },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)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 }