From 91be11c1785935d6cb4758164ed6d7431b8b9472 Mon Sep 17 00:00:00 2001 From: MiMoHo <37556964+MiMoHo@users.noreply.github.com> Date: Sun, 5 Jul 2026 03:05:37 +0200 Subject: [PATCH] Show password count per folder in the folder list (#79) Each folder row now shows a trailing number with how many passwords it directly contains, as requested in #79. Counts are computed once per list as a folderId -> count map from the full decrypted password list, so they stay accurate while searching. Hidden and trashed passwords are excluded to match the passwords shown when the folder is opened; counting is direct-children only (subfolders show their own count). Adds unit tests for the counting logic. Co-Authored-By: Claude Fable 5 --- .../ui/components/ItemList.kt | 25 ++++- .../ui/components/NCPNavHost.kt | 8 ++ .../FolderPasswordCountTest.kt | 95 +++++++++++++++++++ 3 files changed, 127 insertions(+), 1 deletion(-) create mode 100644 app/src/test/java/com/hegocre/nextcloudpasswords/FolderPasswordCountTest.kt diff --git a/app/src/main/java/com/hegocre/nextcloudpasswords/ui/components/ItemList.kt b/app/src/main/java/com/hegocre/nextcloudpasswords/ui/components/ItemList.kt index a14b4493f..a33d7f702 100644 --- a/app/src/main/java/com/hegocre/nextcloudpasswords/ui/components/ItemList.kt +++ b/app/src/main/java/com/hegocre/nextcloudpasswords/ui/components/ItemList.kt @@ -54,6 +54,17 @@ data class ListDecryptionState( val isLoading: Boolean = false ) +/** + * Counts the visible (not hidden, not trashed) passwords per folder id, so a + * folder row can show how many passwords it directly contains. Counts direct + * children only, matching the passwords shown when the folder is opened. + */ +fun folderPasswordCounts(passwords: List): Map = + passwords.asSequence() + .filter { !it.hidden && !it.trashed } + .groupingBy { it.folder } + .eachCount() + @Composable fun MixedLazyColumn( passwords: List? = null, @@ -62,7 +73,8 @@ fun MixedLazyColumn( onPasswordLongClick: ((Password) -> Unit)? = null, onFolderClick: ((Folder) -> Unit)? = null, onFolderLongClick: ((Folder) -> Unit)? = null, - getPainterForUrl: (@Composable (String) -> Painter)? = null + getPainterForUrl: (@Composable (String) -> Painter)? = null, + folderPasswordCounts: Map? = null ) { val context = LocalContext.current val shouldShowIcon by PreferencesManager.getInstance(context).getShowIcons() @@ -89,6 +101,7 @@ fun MixedLazyColumn( items(items = it, key = { folder -> folder.id }) { folder -> FolderRow( folder = folder, + passwordCount = folderPasswordCounts?.let { it[folder.id] ?: 0 }, onFolderClick = onFolderClick, onFolderLongClick = onFolderLongClick ) @@ -179,6 +192,7 @@ fun PasswordRow( fun FolderRow( folder: Folder, modifier: Modifier = Modifier, + passwordCount: Int? = null, onFolderClick: ((Folder) -> Unit)? = null, onFolderLongClick: ((Folder) -> Unit)? = null, ) { @@ -196,6 +210,15 @@ fun FolderRow( headlineContent = { Text(text = folder.label) }, + trailingContent = passwordCount?.let { count -> + { + Text( + text = count.toString(), + style = MaterialTheme.typography.labelLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + }, modifier = modifier .combinedClickable( onClick = { diff --git a/app/src/main/java/com/hegocre/nextcloudpasswords/ui/components/NCPNavHost.kt b/app/src/main/java/com/hegocre/nextcloudpasswords/ui/components/NCPNavHost.kt index e1ddac54e..a7f798448 100644 --- a/app/src/main/java/com/hegocre/nextcloudpasswords/ui/components/NCPNavHost.kt +++ b/app/src/main/java/com/hegocre/nextcloudpasswords/ui/components/NCPNavHost.kt @@ -315,6 +315,9 @@ fun NCPNavHost( it.parent == FoldersApi.DEFAULT_FOLDER_UUID } } + val folderPasswordCounts = remember(passwordsDecryptionState.decryptedList) { + folderPasswordCounts(passwordsDecryptionState.decryptedList ?: emptyList()) + } when { foldersDecryptionState.isLoading || passwordsDecryptionState.isLoading -> { Box(modifier = Modifier.fillMaxSize()) { @@ -343,6 +346,7 @@ fun NCPNavHost( MixedLazyColumn( passwords = filteredPasswordsParentFolder, folders = filteredFoldersParentFolder, + folderPasswordCounts = folderPasswordCounts, onPasswordClick = onPasswordClick, onPasswordLongClick = { if (sessionOpen && (autofillData == null || autofillData.isSave()) && it.editable) @@ -382,6 +386,9 @@ fun NCPNavHost( it.parent == folderUuid } } + val folderPasswordCounts = remember(passwordsDecryptionState.decryptedList) { + folderPasswordCounts(passwordsDecryptionState.decryptedList ?: emptyList()) + } NCPNavHostComposable( modalSheetState = modalSheetState, searchVisibility = searchVisibility, @@ -421,6 +428,7 @@ fun NCPNavHost( MixedLazyColumn( passwords = filteredPasswordsSelectedFolder, folders = filteredFoldersSelectedFolder, + folderPasswordCounts = folderPasswordCounts, onPasswordClick = onPasswordClick, onPasswordLongClick = { if (sessionOpen && (autofillData == null || autofillData.isSave()) && it.editable) diff --git a/app/src/test/java/com/hegocre/nextcloudpasswords/FolderPasswordCountTest.kt b/app/src/test/java/com/hegocre/nextcloudpasswords/FolderPasswordCountTest.kt new file mode 100644 index 000000000..5ac287e2e --- /dev/null +++ b/app/src/test/java/com/hegocre/nextcloudpasswords/FolderPasswordCountTest.kt @@ -0,0 +1,95 @@ +package com.hegocre.nextcloudpasswords + +import com.hegocre.nextcloudpasswords.data.password.Password +import com.hegocre.nextcloudpasswords.ui.components.folderPasswordCounts +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test + +/** + * Unit tests for the per-folder password counting used by the folder list. + */ +class FolderPasswordCountTest { + private fun password( + id: String, + folder: String, + hidden: Boolean = false, + trashed: Boolean = false + ) = Password( + id = id, + label = id, + username = "", + password = "", + url = "", + notes = "", + customFields = "", + status = 0, + statusCode = "GOOD", + hash = "", + folder = folder, + revision = "", + share = null, + shared = false, + cseType = "", + cseKey = "", + sseType = "", + client = "", + hidden = hidden, + trashed = trashed, + favorite = false, + editable = true, + edited = 0, + created = 0, + updated = 0 + ) + + private val folderA = "folder-a" + private val folderB = "folder-b" + + @Test + fun countsPasswordsPerFolder() { + val counts = folderPasswordCounts( + listOf( + password("1", folderA), + password("2", folderA), + password("3", folderB) + ) + ) + assertEquals(2, counts[folderA]) + assertEquals(1, counts[folderB]) + } + + @Test + fun folderWithoutPasswordsIsAbsent() { + val counts = folderPasswordCounts(listOf(password("1", folderA))) + assertNull(counts["empty-folder"]) + } + + @Test + fun emptyListYieldsEmptyMap() { + assertEquals(emptyMap(), folderPasswordCounts(emptyList())) + } + + @Test + fun hiddenAndTrashedPasswordsAreExcluded() { + val counts = folderPasswordCounts( + listOf( + password("1", folderA), + password("2", folderA, hidden = true), + password("3", folderA, trashed = true) + ) + ) + assertEquals(1, counts[folderA]) + } + + @Test + fun folderWithOnlyHiddenOrTrashedPasswordsIsAbsent() { + val counts = folderPasswordCounts( + listOf( + password("1", folderA, hidden = true), + password("2", folderA, trashed = true) + ) + ) + assertNull(counts[folderA]) + } +}