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