diff --git a/README.md b/README.md
index 219683a..1a60987 100644
--- a/README.md
+++ b/README.md
@@ -75,6 +75,7 @@ app/src/main/java/com/pledgerio/app/
- **Currencies** — Fetched from API, cached in Room, used for `formatCurrency()` across the app
- **Budgets** — Initial budget setup on 404; monthly overview per expense group; manage groups (add/edit); detail screen (see [Budgets](docs/BUDGETS.md))
- **Reports** — Report type selector UI (chart data integration in progress)
+- **Search** — Global search from the Dashboard for transactions (last 6 months), owned/counterparty accounts (cache-first owned), and categories; category rows open Transactions for the current month
- **Settings** — Storage, biometric unlock, **language** (English / Dutch / German / system), theme, display currency, finance experience mode (Guided/Power), **budget alerts** (enable + threshold), in-app bug reports (logs + GitHub issue), logout
- **Offline** — Room cache with network fallback; periodic sync via WorkManager (accounts, currencies, budget alerts with deep links)
- **Account logos** — `iconFileCode` loaded from `GET /v2/api/files/{fileCode}` on account and transaction detail screens
@@ -101,6 +102,7 @@ app/src/main/java/com/pledgerio/app/
| `budgets` | Budget overview (current month); add expense groups via FAB |
| `budget/{id}` | Expense group detail; edit monthly budget |
| `reports` | Reports (bottom tab) |
+| `search` | Global search (from Dashboard) |
| `settings` | Settings |
## Documentation
diff --git a/app/src/main/java/com/pledgerio/app/ui/navigation/NavGraph.kt b/app/src/main/java/com/pledgerio/app/ui/navigation/NavGraph.kt
index 37ccfcc..335663a 100644
--- a/app/src/main/java/com/pledgerio/app/ui/navigation/NavGraph.kt
+++ b/app/src/main/java/com/pledgerio/app/ui/navigation/NavGraph.kt
@@ -32,6 +32,7 @@ import com.pledgerio.app.ui.transactions.TransactionsScreen
import com.pledgerio.app.ui.transactions.TransactionsViewModel
import com.pledgerio.app.ui.transactions.scan.InvoiceScanScreen
import com.pledgerio.app.ui.accounts.AccountDetailViewModel
+import java.time.YearMonth
@Composable
fun NavGraph(
@@ -128,6 +129,17 @@ fun NavGraph(
onNavigateToAccount = { id ->
navController.navigate(Screen.AccountDetail.createRoute(id))
},
+ onNavigateToCategory = { categoryId, categoryName ->
+ val month = YearMonth.now()
+ navController.navigate(
+ Screen.Transactions.createRoute(
+ categoryId = categoryId,
+ categoryName = categoryName,
+ year = month.year,
+ month = month.monthValue,
+ ),
+ )
+ },
viewModel = searchViewModel,
)
}
diff --git a/app/src/main/java/com/pledgerio/app/ui/search/SearchScreen.kt b/app/src/main/java/com/pledgerio/app/ui/search/SearchScreen.kt
index 29cbd92..91dfb25 100644
--- a/app/src/main/java/com/pledgerio/app/ui/search/SearchScreen.kt
+++ b/app/src/main/java/com/pledgerio/app/ui/search/SearchScreen.kt
@@ -40,9 +40,13 @@ fun SearchScreen(
onNavigateBack: () -> Unit,
onNavigateToTransaction: (Long) -> Unit,
onNavigateToAccount: (Long) -> Unit,
+ onNavigateToCategory: (Long, String) -> Unit,
viewModel: SearchViewModel = hiltViewModel(),
) {
val uiState by viewModel.uiState.collectAsState()
+ val hasResults = uiState.transactions.isNotEmpty() ||
+ uiState.accounts.isNotEmpty() ||
+ uiState.categories.isNotEmpty()
Scaffold(
topBar = {
@@ -75,15 +79,6 @@ fun SearchScreen(
)
Spacer(modifier = Modifier.height(12.dp))
when {
- uiState.isSearching -> {
- CircularProgressIndicator(modifier = Modifier.padding(24.dp))
- }
- uiState.error != null -> {
- Text(
- text = uiState.error ?: "",
- color = MaterialTheme.colorScheme.error,
- )
- }
uiState.query.isBlank() -> {
Text(
text = stringResource(R.string.search_prompt),
@@ -91,8 +86,21 @@ fun SearchScreen(
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
+ uiState.isSearching && !hasResults -> {
+ CircularProgressIndicator(modifier = Modifier.padding(24.dp))
+ }
else -> {
LazyColumn(verticalArrangement = Arrangement.spacedBy(8.dp)) {
+ if (uiState.error != null) {
+ item {
+ Text(
+ text = stringResource(R.string.search_transactions_error),
+ color = MaterialTheme.colorScheme.error,
+ style = MaterialTheme.typography.bodyMedium,
+ modifier = Modifier.padding(bottom = 4.dp),
+ )
+ }
+ }
if (uiState.transactions.isNotEmpty()) {
item {
Text(
@@ -148,16 +156,18 @@ fun SearchScreen(
)
}
items(uiState.categories, key = { it.id }) { category ->
- PledgerCard(modifier = Modifier.fillMaxWidth()) {
+ PledgerCard(
+ modifier = Modifier
+ .fillMaxWidth()
+ .clickable {
+ onNavigateToCategory(category.id, category.name)
+ },
+ ) {
Text(category.name, style = MaterialTheme.typography.bodyLarge)
}
}
}
- if (
- uiState.transactions.isEmpty() &&
- uiState.accounts.isEmpty() &&
- uiState.categories.isEmpty()
- ) {
+ if (!hasResults && uiState.error == null) {
item {
Text(
stringResource(R.string.search_no_results),
diff --git a/app/src/main/java/com/pledgerio/app/ui/search/SearchViewModel.kt b/app/src/main/java/com/pledgerio/app/ui/search/SearchViewModel.kt
index b99246b..7290326 100644
--- a/app/src/main/java/com/pledgerio/app/ui/search/SearchViewModel.kt
+++ b/app/src/main/java/com/pledgerio/app/ui/search/SearchViewModel.kt
@@ -18,6 +18,7 @@ import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.debounce
import kotlinx.coroutines.flow.distinctUntilChanged
+import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import java.time.YearMonth
@@ -92,12 +93,9 @@ class SearchViewModel @Inject constructor(
page = 0,
pageSize = 20,
)
- val ownedAccounts = when (val result = accountRepository.refreshOwnedAccounts()) {
- is Resource.Success -> result.data.filter {
- it.name.contains(query, ignoreCase = true)
- }
- else -> emptyList()
- }
+ val ownedAccounts = accountRepository.observeOwnedAccounts()
+ .first()
+ .filter { it.name.contains(query, ignoreCase = true) }
val partyAccounts = when (
val result = accountRepository.getCounterpartyAccountsPage(
offset = 0,
@@ -120,6 +118,7 @@ class SearchViewModel @Inject constructor(
_uiState.update {
it.copy(
isSearching = false,
+ error = null,
transactions = txResult.data.items,
accounts = accounts,
categories = categories,
@@ -131,6 +130,7 @@ class SearchViewModel @Inject constructor(
it.copy(
isSearching = false,
error = txResult.message,
+ transactions = emptyList(),
accounts = accounts,
categories = categories,
)
diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml
index c1907c0..8bbb8b5 100644
--- a/app/src/main/res/values-de/strings.xml
+++ b/app/src/main/res/values-de/strings.xml
@@ -96,6 +96,7 @@
Transaktionen, Konten, Kategorien suchen…
Tippe, um in deinen Daten zu suchen.
Keine Ergebnisse gefunden.
+ Transaktionen konnten nicht geladen werden
Transaktionen
Konten
Kategorien
diff --git a/app/src/main/res/values-nl/strings.xml b/app/src/main/res/values-nl/strings.xml
index 703b955..9b937f2 100644
--- a/app/src/main/res/values-nl/strings.xml
+++ b/app/src/main/res/values-nl/strings.xml
@@ -96,6 +96,7 @@
Zoek transacties, rekeningen, categorieën…
Typ om in je gegevens te zoeken.
Geen resultaten gevonden.
+ Transacties konden niet worden geladen
Transacties
Rekeningen
Categorieën
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index 72c123b..cecfe0b 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -96,6 +96,7 @@
Search transactions, accounts, categories…
Type to search across your data.
No results found.
+ Couldn’t load transactions
Transactions
Accounts
Categories
diff --git a/app/src/test/java/com/pledgerio/app/ui/search/SearchViewModelTest.kt b/app/src/test/java/com/pledgerio/app/ui/search/SearchViewModelTest.kt
new file mode 100644
index 0000000..5dfccb8
--- /dev/null
+++ b/app/src/test/java/com/pledgerio/app/ui/search/SearchViewModelTest.kt
@@ -0,0 +1,205 @@
+package com.pledgerio.app.ui.search
+
+import com.pledgerio.app.domain.model.Account
+import com.pledgerio.app.domain.model.Category
+import com.pledgerio.app.domain.model.PagedAccounts
+import com.pledgerio.app.domain.model.Transaction
+import com.pledgerio.app.domain.model.TransactionType
+import com.pledgerio.app.domain.repository.AccountRepository
+import com.pledgerio.app.domain.repository.CategoryRepository
+import com.pledgerio.app.domain.repository.PagedResult
+import com.pledgerio.app.domain.repository.TransactionRepository
+import com.pledgerio.app.util.MainDispatcherRule
+import com.pledgerio.app.util.Resource
+import com.pledgerio.app.util.SearchDefaults
+import io.mockk.coEvery
+import io.mockk.coVerify
+import io.mockk.every
+import io.mockk.mockk
+import io.mockk.verify
+import kotlinx.coroutines.flow.flowOf
+import kotlinx.coroutines.test.advanceTimeBy
+import kotlinx.coroutines.test.advanceUntilIdle
+import kotlinx.coroutines.test.runTest
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertNull
+import org.junit.Assert.assertTrue
+import org.junit.Rule
+import org.junit.Test
+import java.time.LocalDate
+
+@OptIn(kotlinx.coroutines.ExperimentalCoroutinesApi::class)
+class SearchViewModelTest {
+
+ @get:Rule
+ val mainDispatcherRule = MainDispatcherRule()
+
+ private val transactionRepository = mockk()
+ private val accountRepository = mockk()
+ private val categoryRepository = mockk()
+
+ private fun transaction(id: Long = 1L, description: String = "Coffee") = Transaction(
+ id = id,
+ description = description,
+ amount = 3.5,
+ type = TransactionType.DEBIT,
+ date = LocalDate.now(),
+ )
+
+ private fun ownedAccount(id: Long = 10L, name: String = "Checking") =
+ Account(id = id, name = name, typeCode = "default")
+
+ private fun category(id: Long = 20L, name: String = "Food") =
+ Category(id = id, name = name)
+
+ private fun createViewModel(): SearchViewModel =
+ SearchViewModel(transactionRepository, accountRepository, categoryRepository)
+
+ private suspend fun kotlinx.coroutines.test.TestScope.searchAndIdle(
+ viewModel: SearchViewModel,
+ query: String,
+ ) {
+ viewModel.onQueryChanged(query)
+ advanceTimeBy(SearchDefaults.DEBOUNCE_MS)
+ advanceUntilIdle()
+ }
+
+ private fun stubHappyPath(
+ query: String = "co",
+ transactions: List = listOf(transaction()),
+ owned: List = listOf(ownedAccount()),
+ parties: List = emptyList(),
+ categories: List = listOf(category()),
+ ) {
+ every { accountRepository.observeOwnedAccounts() } returns flowOf(owned)
+ coEvery {
+ accountRepository.getCounterpartyAccountsPage(0, 25, query)
+ } returns Resource.Success(
+ PagedAccounts(parties, totalRecords = parties.size.toLong(), offset = 0, pageSize = 25),
+ )
+ coEvery { categoryRepository.searchCategories(query) } returns Resource.Success(categories)
+ coEvery {
+ transactionRepository.getTransactionsPage(
+ startDate = any(),
+ endDate = any(),
+ filters = match { it.description == query },
+ page = 0,
+ pageSize = 20,
+ )
+ } returns Resource.Success(
+ PagedResult(
+ items = transactions,
+ totalRecords = transactions.size.toLong(),
+ totalPages = 1,
+ pageSize = 20,
+ ),
+ )
+ }
+
+ @Test
+ fun `blank query does not search repositories`() = runTest(mainDispatcherRule.dispatcher) {
+ val viewModel = createViewModel()
+ advanceUntilIdle()
+
+ viewModel.onQueryChanged("")
+ advanceTimeBy(SearchDefaults.DEBOUNCE_MS)
+ advanceUntilIdle()
+
+ assertEquals("", viewModel.uiState.value.query)
+ assertTrue(viewModel.uiState.value.transactions.isEmpty())
+ assertNull(viewModel.uiState.value.error)
+ coVerify(exactly = 0) {
+ transactionRepository.getTransactionsPage(
+ startDate = any(),
+ endDate = any(),
+ filters = any(),
+ page = any(),
+ pageSize = any(),
+ )
+ }
+ verify(exactly = 0) { accountRepository.observeOwnedAccounts() }
+ coVerify(exactly = 0) { accountRepository.refreshOwnedAccounts() }
+ }
+
+ @Test
+ fun `success merges transactions accounts and categories`() = runTest(mainDispatcherRule.dispatcher) {
+ val party = Account(id = 11L, name = "Corner Shop", typeCode = "creditor")
+ stubHappyPath(
+ query = "co",
+ transactions = listOf(transaction(description = "Coffee")),
+ owned = listOf(ownedAccount(name = "Corporate Checking")),
+ parties = listOf(party),
+ categories = listOf(category(name = "Coffee shops")),
+ )
+
+ val viewModel = createViewModel()
+ searchAndIdle(viewModel, "co")
+
+ val state = viewModel.uiState.value
+ assertFalse(state.isSearching)
+ assertNull(state.error)
+ assertEquals(1, state.transactions.size)
+ assertEquals("Coffee", state.transactions.first().description)
+ assertEquals(listOf(10L, 11L), state.accounts.map { it.id })
+ assertEquals(1, state.categories.size)
+ assertEquals("Coffee shops", state.categories.first().name)
+ coVerify(exactly = 0) { accountRepository.refreshOwnedAccounts() }
+ }
+
+ @Test
+ fun `transaction error still returns accounts and categories`() = runTest(mainDispatcherRule.dispatcher) {
+ every { accountRepository.observeOwnedAccounts() } returns flowOf(
+ listOf(ownedAccount(name = "Checking")),
+ )
+ coEvery {
+ accountRepository.getCounterpartyAccountsPage(0, 25, "ch")
+ } returns Resource.Success(
+ PagedAccounts(emptyList(), totalRecords = 0, offset = 0, pageSize = 25),
+ )
+ coEvery { categoryRepository.searchCategories("ch") } returns Resource.Success(
+ listOf(category(name = "Charity")),
+ )
+ coEvery {
+ transactionRepository.getTransactionsPage(
+ startDate = any(),
+ endDate = any(),
+ filters = match { it.description == "ch" },
+ page = 0,
+ pageSize = 20,
+ )
+ } returns Resource.Error("Network down")
+
+ val viewModel = createViewModel()
+ searchAndIdle(viewModel, "ch")
+
+ val state = viewModel.uiState.value
+ assertFalse(state.isSearching)
+ assertEquals("Network down", state.error)
+ assertTrue(state.transactions.isEmpty())
+ assertEquals(1, state.accounts.size)
+ assertEquals("Checking", state.accounts.first().name)
+ assertEquals(1, state.categories.size)
+ assertEquals("Charity", state.categories.first().name)
+ coVerify(exactly = 0) { accountRepository.refreshOwnedAccounts() }
+ }
+
+ @Test
+ fun `owned accounts path uses observeOwnedAccounts not refresh`() =
+ runTest(mainDispatcherRule.dispatcher) {
+ stubHappyPath(
+ query = "check",
+ owned = listOf(
+ ownedAccount(id = 1L, name = "Checking"),
+ ownedAccount(id = 2L, name = "Savings"),
+ ),
+ )
+
+ val viewModel = createViewModel()
+ searchAndIdle(viewModel, "check")
+
+ assertEquals(listOf(1L), viewModel.uiState.value.accounts.map { it.id })
+ verify(atLeast = 1) { accountRepository.observeOwnedAccounts() }
+ coVerify(exactly = 0) { accountRepository.refreshOwnedAccounts() }
+ }
+}
diff --git a/docs/adr/017-deep-links-and-reports.md b/docs/adr/017-deep-links-and-reports.md
index de862ed..aa3d910 100644
--- a/docs/adr/017-deep-links-and-reports.md
+++ b/docs/adr/017-deep-links-and-reports.md
@@ -50,6 +50,6 @@ Power mode expands transaction filters by default on first load (unless opened v
- Custom scheme only; App Links (`https://`) can be added later with host verification.
- Report partitions depend on server support for `category`, `account`, and `balance` partition keys; errors surface in UI.
-- Search loads owned accounts via refresh — acceptable for MVP, may be optimized with cache-only reads later.
+- Search filters owned accounts from `observeOwnedAccounts()` (cache-first / SWR); counterparties and transactions still hit the network.
- Reports Overview loads prior-month income/expense (and categories) in parallel for MoM Δ/%; prior-month failures are soft and do not fail the overview.
- Report rows can navigate to Transactions (category/expense + month) or Account detail when ids are resolved; category name→id matching may leave some rows non-clickable.
diff --git a/docs/design/expanded-global-search.md b/docs/design/expanded-global-search.md
new file mode 100644
index 0000000..589f298
--- /dev/null
+++ b/docs/design/expanded-global-search.md
@@ -0,0 +1,79 @@
+# Design: Expanded global search
+
+**Status:** Approved for implementation
+**Issue:** [pledger-io/.github#36](https://github.com/pledger-io/.github/issues/36)
+**Branch:** `cursor/expanded-global-search-25b7`
+
+## Problem
+
+Search MVP works but:
+
+1. Owned accounts call `refreshOwnedAccounts()` on every query (network-first).
+2. Category rows are not clickable.
+3. `SearchScreen` treats `error != null` as exclusive — hides account/category hits even though the ViewModel already populates them on tx failure.
+4. Missing `SearchViewModel` tests and README mention.
+
+## Goals (from #36)
+
+| Goal | Approach |
+|------|----------|
+| Cache-first owned accounts | Filter `accountRepository.observeOwnedAccounts()` (already SWR / `launchIfStale`); do **not** call `refreshOwnedAccounts()` in search |
+| Category → transactions | `onNavigateToCategory(id, name)` → `Screen.Transactions.createRoute(categoryId, categoryName, year, month)` with current month (or last 6 months start — prefer **current** `YearMonth.now()` to match report drill-downs) |
+| Partial success UX | Always show result sections when any list is non-empty; render tx `error` as a non-blocking banner/text above the list (not an exclusive branch) |
+| Tests + docs | `SearchViewModelTest` happy + partial failure; README bullet |
+
+## Non-goals
+
+Unchanged from issue #36 (budgets/contracts search, infinite scroll, deep link, SearchRepository, outbox).
+
+## Implementation notes
+
+### ViewModel
+
+```kotlin
+val ownedAccounts = accountRepository.observeOwnedAccounts()
+ .first()
+ .filter { it.name.contains(query, ignoreCase = true) }
+```
+
+Keep counterparties + categories paths. On tx Error: set `error` **and** keep `transactions = emptyList()` (or prior?) — prefer empty txs + accounts/categories filled. Clear error on next successful search / blank query.
+
+Optional: rename `error` → `transactionsError` in UI state for clarity (nice-to-have).
+
+### Screen
+
+Change `when` order:
+
+1. blank query → prompt
+2. searching && all empty → spinner (or keep spinner only while searching with no prior results)
+3. else → LazyColumn with optional error banner + sections
+
+Spinner-only when `isSearching && no results yet` avoids flicker wiping partial content on re-query.
+
+### NavGraph
+
+Wire `onNavigateToCategory` like reports category drill-down.
+
+### Strings
+
+Reuse existing search strings; add `search_transactions_error` if needed for banner (“Couldn’t load transactions”).
+
+### Docs
+
+README feature list + short note in ADR-017 consequence that owned accounts are cache-first in search.
+
+## Tests
+
+- Blank → no search calls (or cleared state)
+- Success merges txs + accounts + categories
+- Tx Error still returns accounts/categories; `error` set
+- Owned path does not call `refreshOwnedAccounts` (verify with mockk)
+- Category navigation is UI/callback — covered by Screen route test if needed
+
+## Order
+
+1. ViewModel cache-first + state clarity
+2. Screen partial UX + category click
+3. NavGraph
+4. Tests + README/ADR
+5. `testDebugUnitTest`, lint, assembleDebug