Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
12 changes: 12 additions & 0 deletions app/src/main/java/com/pledgerio/app/ui/navigation/NavGraph.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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,
)
}
Expand Down
40 changes: 25 additions & 15 deletions app/src/main/java/com/pledgerio/app/ui/search/SearchScreen.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down Expand Up @@ -75,24 +79,28 @@ 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),
style = MaterialTheme.typography.bodyMedium,
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(
Expand Down Expand Up @@ -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),
Expand Down
12 changes: 6 additions & 6 deletions app/src/main/java/com/pledgerio/app/ui/search/SearchViewModel.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand All @@ -120,6 +118,7 @@ class SearchViewModel @Inject constructor(
_uiState.update {
it.copy(
isSearching = false,
error = null,
transactions = txResult.data.items,
accounts = accounts,
categories = categories,
Expand All @@ -131,6 +130,7 @@ class SearchViewModel @Inject constructor(
it.copy(
isSearching = false,
error = txResult.message,
transactions = emptyList(),
accounts = accounts,
categories = categories,
)
Expand Down
1 change: 1 addition & 0 deletions app/src/main/res/values-de/strings.xml
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,7 @@
<string name="search_hint">Transaktionen, Konten, Kategorien suchen…</string>
<string name="search_prompt">Tippe, um in deinen Daten zu suchen.</string>
<string name="search_no_results">Keine Ergebnisse gefunden.</string>
<string name="search_transactions_error">Transaktionen konnten nicht geladen werden</string>
<string name="search_section_transactions">Transaktionen</string>
<string name="search_section_accounts">Konten</string>
<string name="search_section_categories">Kategorien</string>
Expand Down
1 change: 1 addition & 0 deletions app/src/main/res/values-nl/strings.xml
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,7 @@
<string name="search_hint">Zoek transacties, rekeningen, categorieën…</string>
<string name="search_prompt">Typ om in je gegevens te zoeken.</string>
<string name="search_no_results">Geen resultaten gevonden.</string>
<string name="search_transactions_error">Transacties konden niet worden geladen</string>
<string name="search_section_transactions">Transacties</string>
<string name="search_section_accounts">Rekeningen</string>
<string name="search_section_categories">Categorieën</string>
Expand Down
1 change: 1 addition & 0 deletions app/src/main/res/values/strings.xml
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,7 @@
<string name="search_hint">Search transactions, accounts, categories…</string>
<string name="search_prompt">Type to search across your data.</string>
<string name="search_no_results">No results found.</string>
<string name="search_transactions_error">Couldn’t load transactions</string>
<string name="search_section_transactions">Transactions</string>
<string name="search_section_accounts">Accounts</string>
<string name="search_section_categories">Categories</string>
Expand Down
205 changes: 205 additions & 0 deletions app/src/test/java/com/pledgerio/app/ui/search/SearchViewModelTest.kt
Original file line number Diff line number Diff line change
@@ -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<TransactionRepository>()
private val accountRepository = mockk<AccountRepository>()
private val categoryRepository = mockk<CategoryRepository>()

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<Transaction> = listOf(transaction()),
owned: List<Account> = listOf(ownedAccount()),
parties: List<Account> = emptyList(),
categories: List<Category> = 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() }
}
}
2 changes: 1 addition & 1 deletion docs/adr/017-deep-links-and-reports.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Loading
Loading