Coverage Summary for Class: TransactionsByAccountViewModel (com.georgeci.moneysurfer.feature.transaction.list)

Class Method, % Branch, % Line, % Instruction, %
TransactionsByAccountViewModel 100% (12/12) 89.5% (51/57) 100% (91/91) 98.7% (519/526)
TransactionsByAccountViewModel$Loaded 100% (1/1) 100% (4/4) 100% (17/17)
TransactionsByAccountViewModel$observeData$1 100% (1/1) 100% (2/2) 100% (19/19) 100% (123/123)
TransactionsByAccountViewModel$observeData$1$1 100% (1/1) 100% (7/7) 100% (11/11)
TransactionsByAccountViewModel$observeData$1$2 100% (1/1) 100% (2/2) 100% (2/2) 100% (23/23)
TransactionsByAccountViewModel$observeData$1$invokeSuspend$$inlined$flatMapLatest$1 0% (0/1)
TransactionsByAccountViewModel$observeData$1$invokeSuspend$$inlined$map$1 0% (0/2)
TransactionsByAccountViewModel$observeData$1$invokeSuspend$$inlined$map$1$1
TransactionsByAccountViewModel$observeData$1$invokeSuspend$$inlined$map$1$2 0% (0/1)
TransactionsByAccountViewModel$observeData$1$invokeSuspend$$inlined$map$1$2$1
TransactionsByAccountViewModel$observeData$1$loaded$2 100% (1/1) 100% (5/5) 100% (13/13)
TransactionsByAccountViewModel$observeData$1$loaded$3 100% (1/1) 100% (1/1) 100% (12/12)
TransactionsByAccountViewModel$observeData$1$loaded$4$1 100% (1/1) 100% (1/1) 100% (12/12)
TransactionsByAccountViewModel$onEvent$1 100% (1/1) 100% (1/1) 100% (17/17)
TransactionsByAccountViewModel$onEvent$2 100% (1/1) 100% (4/4) 100% (20/20)
TransactionsByAccountViewModel$Page 100% (1/1) 100% (1/1) 100% (12/12)
TransactionsByAccountViewModel$pageFlow-bUNphrE$$inlined$flatMapLatest$1 0% (0/1)
TransactionsByAccountViewModel$pageFlow_bUNphrE$lambda$0$$inlined$map$1 0% (0/2)
TransactionsByAccountViewModel$pageFlow_bUNphrE$lambda$0$$inlined$map$1$1
TransactionsByAccountViewModel$pageFlow_bUNphrE$lambda$0$$inlined$map$1$2 0% (0/1)
TransactionsByAccountViewModel$pageFlow_bUNphrE$lambda$0$$inlined$map$1$2$1
TransactionsByAccountViewModel$Period 100% (1/1) 100% (5/5) 100% (22/22)
TransactionsByAccountViewModel$WhenMappings
Total 74.2% (23/31) 90.2% (55/61) 100% (141/141) 99.1% (801/808)


 package com.georgeci.moneysurfer.feature.transaction.list
 
 import com.georgeci.moneysurfer.domain.model.Account
 import com.georgeci.moneysurfer.domain.model.CategorizedTransaction
 import com.georgeci.moneysurfer.domain.model.Category
 import com.georgeci.moneysurfer.domain.model.TransactionTotal
 import com.georgeci.moneysurfer.domain.preferences.TransactionPeriodMode
 import com.georgeci.moneysurfer.domain.preferences.UiPreferences
 import com.georgeci.moneysurfer.domain.primitives.AccountId
 import com.georgeci.moneysurfer.domain.primitives.ClockUseCase
 import com.georgeci.moneysurfer.domain.usecase.GetAccountByIdUseCase
 import com.georgeci.moneysurfer.domain.usecase.GetAccountsUseCase
 import com.georgeci.moneysurfer.domain.usecase.GetCategoriesUseCase
 import com.georgeci.moneysurfer.domain.usecase.GetTransactionsByAccountUseCase
 import com.georgeci.moneysurfer.domain.util.TransactionPeriodWindow
 import com.georgeci.moneysurfer.domain.util.isoWeek
 import com.georgeci.moneysurfer.domain.util.shiftPeriod
 import com.georgeci.moneysurfer.feature.transaction.filter.TransactionDateRange
 import com.georgeci.moneysurfer.feature.transaction.filter.TransactionFilterStore
 import com.georgeci.moneysurfer.feature.transaction.filter.TransactionFilters
 import com.georgeci.moneysurfer.feature.transaction.filter.TransactionSort
 import com.georgeci.moneysurfer.feature.transaction.filter.compile
 import com.georgeci.moneysurfer.feature.transaction.filter.resolveWindow
 import com.georgeci.moneysurfer.navigation.DeleteTransactionWithUndo
 import com.georgeci.moneysurfer.utils.MviViewModel
 import kotlinx.coroutines.ExperimentalCoroutinesApi
 import kotlinx.coroutines.flow.Flow
 import kotlinx.coroutines.flow.MutableStateFlow
 import kotlinx.coroutines.flow.combine
 import kotlinx.coroutines.flow.distinctUntilChanged
 import kotlinx.coroutines.flow.flatMapLatest
 import kotlinx.coroutines.flow.map
 import kotlinx.coroutines.flow.onEach
 import kotlinx.datetime.LocalDate
 import kotlinx.datetime.TimeZone
 import kotlinx.datetime.number
 import kotlinx.datetime.toLocalDateTime
 import moneysurfer.feature.transaction.generated.resources.Res
 import moneysurfer.feature.transaction.generated.resources.transactions_list_delete_undo
 import moneysurfer.feature.transaction.generated.resources.transactions_list_deleted_snackbar
 import org.koin.core.annotation.KoinViewModel
 
 /** Rows fetched per page. One screenful is ~15 rows, so a page covers a long scroll burst. */
 private const val PAGE_SIZE = 200
 
 /**
  * Enough rendered rows that the list is comfortably scrollable, so the normal scroll-driven
  * load-more can take over. Below this, a sparse page auto-advances — see
  * [shouldLoadMoreToFillList].
  */
 private const val AUTO_LOAD_UNTIL_SCROLLABLE = 20
 
 /**
  * Whether a page renders too few rows to scroll and should pull its next raw page.
  *
  * Two things thin a page out. A filter that matches only a handful of the loaded rows leaves too
  * few to scroll, so without this the user could never reach matches deeper in the window and an
  * all-miss first page would wrongly read as "nothing matches". Collapsing does the same without any
  * filter: a page made mostly of one receipt's legs renders as a couple of visible rows, and the raw
  * `rows.size > limit` that drives [TransactionsByAccountState.Content.canLoadMore] stopped matching
  * what is on screen the moment groups started collapsing.
  *
  * Counting *rendered* rows is what covers both. Bounded by the window: a month stops at the month's
  * rows.
  *
  * The real cure for the filter half is to filter in SQL (the DAO already has an FTS `searchByText`);
  * this keeps the in-memory page honest until that lands.
  */
 private fun shouldLoadMoreToFillList(content: TransactionsByAccountState.Content): Boolean =
     content.canLoadMore && content.renderedRowCount < AUTO_LOAD_UNTIL_SCROLLABLE
 
 @KoinViewModel
 class TransactionsByAccountViewModel(
     accountId: AccountId?,
     private val getTransactionsByAccount: GetTransactionsByAccountUseCase,
     private val getAccountById: GetAccountByIdUseCase,
     private val getAccounts: GetAccountsUseCase,
     private val getCategories: GetCategoriesUseCase,
     private val filterStore: TransactionFilterStore,
     private val uiPreferences: UiPreferences,
     private val clock: ClockUseCase,
     private val deleteWithUndo: DeleteTransactionWithUndo,
 ) : MviViewModel<TransactionsByAccountState, TransactionsByAccountEvent, TransactionsByAccountEffect>(
     initialState = TransactionsByAccountState.Loading(accountId = accountId),
 ) {
 
     private val zone = TimeZone.currentSystemDefault()
     private val today: LocalDate get() = clock.now().toLocalDateTime(zone).date
 
     /**
      * A date inside the visible period, not the period itself — the window is derived from it and
      * the current mode, so switching month → week keeps the user roughly where they were.
      *
      * Starts at today and is intentionally *not* persisted: reopening the list on a period the
      * user last browsed weeks ago would look like missing data.
      */
     private val anchorDate = MutableStateFlow(today)
 
     /** Grows by [PAGE_SIZE] on each load-more; reset whenever the window changes. */
     private val pageLimit = MutableStateFlow(PAGE_SIZE)
 
     init {
         observeData()
     }
 
     override fun onEvent(event: TransactionsByAccountEvent) {
         when (event) {
             TransactionsByAccountEvent.OnBackClick ->
                 postSideEffect(TransactionsByAccountEffect.NavigateBack)
             TransactionsByAccountEvent.OnAddTransactionClick ->
                 postSideEffect(TransactionsByAccountEffect.NavigateToTransactionCreation(currentState.accountId))
             is TransactionsByAccountEvent.OnTransactionClick ->
                 postSideEffect(TransactionsByAccountEffect.NavigateToTransactionDetails(event.transactionId))
             is TransactionsByAccountEvent.OnSearchQueryChanged -> filterStore.setQuery(event.query)
             TransactionsByAccountEvent.OnClearFiltersClick -> {
                 // The search text goes too, unlike `clear()` on its own: this is the empty state's
                 // CTA, and it promises the list back — leaving the query applied would answer the
                 // tap with the same empty screen.
                 filterStore.clear()
                 filterStore.setQuery("")
             }
             TransactionsByAccountEvent.OnOpenFiltersClick ->
                 // Carry the anchor the list is paged to: the filter screen's live result count
                 // resolves the same window from it, so `Apply · N results` matches the list the
                 // user returns to rather than always counting today's period.
                 postSideEffect(
                     TransactionsByAccountEffect.NavigateToFilters(
                         accountId = currentState.accountId,
                         anchorEpochDay = anchorDate.value.toEpochDays(),
                     ),
                 )
             TransactionsByAccountEvent.OnPreviousPeriodClick -> shiftAnchor(by = -1)
             TransactionsByAccountEvent.OnNextPeriodClick -> shiftAnchor(by = 1)
             is TransactionsByAccountEvent.OnPeriodModeChanged -> launch {
                 uiPreferences.transactionsPeriodMode.set(event.mode)
             }
             TransactionsByAccountEvent.OnLoadMore -> loadMore()
             // No optimistic removal: the list is fed by a database Flow, so the row disappears
             // when the delete lands and comes back by itself when the Undo does.
             is TransactionsByAccountEvent.OnDeleteTransaction -> launch {
                 deleteWithUndo(
                     transactionId = event.transactionId,
                     message = Res.string.transactions_list_deleted_snackbar,
                     undoLabel = Res.string.transactions_list_delete_undo,
                 )
             }
         }
     }
 
     private fun shiftAnchor(by: Int) {
         val mode = (currentState as? TransactionsByAccountState.Content)?.periodMode ?: return
         anchorDate.value = shiftPeriod(mode, anchorDate.value, by)
     }
 
     private fun loadMore() {
         val content = currentState as? TransactionsByAccountState.Content ?: return
         if (!content.canLoadMore) return
         pageLimit.value += PAGE_SIZE
     }
 
     /**
      * Two layers, on purpose. The *window* — pager mode, anchor and the filters' date range — is
      * what the database is queried with, and a change to it invalidates paging. Every other filter
      * narrows the rows already loaded, so typing in the search field must not throw the user back
      * to page one.
      */
     @OptIn(ExperimentalCoroutinesApi::class)
     private fun observeData() {
         launch {
             val accountId = currentState.accountId
             val account = if (accountId != null) getAccountById(accountId) else null
 
             val loaded = combine(
                 uiPreferences.transactionsPeriodMode.flow.distinctUntilChanged(),
                 anchorDate,
                 filterStore.filters.map { it.dateRange }.distinctUntilChanged(),
             ) { mode, anchor, dateRange ->
                 Period(
                     mode = mode,
                     anchor = anchor,
                     dateRange = dateRange,
                     window = resolveWindow(dateRange, mode, anchor, today),
                 )
             }
                 .distinctUntilChanged()
                 // A new window invalidates the accumulated paging: the first page of the new
                 // period is what the user is about to look at, not page 7 of the old one.
                 .onEach { pageLimit.value = PAGE_SIZE }
                 .flatMapLatest { period ->
                     combine(
                         pageFlow(accountId, period),
                         getTransactionsByAccount.totals(accountId, period.window),
                     ) { page, totals -> Loaded(period = period, page = page, totals = totals) }
                 }
 
             combine(
                 loaded,
                 filterStore.filters,
                 getAccounts(),
                 getCategories(),
             ) { rows, filters, accounts, categories ->
                 buildContent(
                     accountId = accountId,
                     account = account,
                     loaded = rows,
                     filters = filters,
                     accounts = accounts,
                     categories = categories,
                 )
             }.collect { content ->
                 updateState { content }
                 // Filtering and split-collapsing both run in memory over the loaded page, but the
                 // scroll-driven load-more can only fire once the rendered list is long enough to
                 // scroll. While too few rows are rendered to scroll, keep pulling the next raw page
                 // until the list is scrollable again (normal paging takes over) or the window is
                 // exhausted.
                 if (shouldLoadMoreToFillList(content)) pageLimit.value += PAGE_SIZE
             }
         }
     }
 
     private fun pageFlow(accountId: AccountId?, period: Period): Flow<Page> =
         pageLimit.flatMapLatest { limit ->
             // One row over the page: its presence is what "there is more" means, and it costs one
             // row rather than a second COUNT query.
             getTransactionsByAccount.window(accountId, period.window, limit + 1)
                 .map { rows -> Page(rows = rows, limit = limit) }
         }
 
     private fun buildContent(
         accountId: AccountId?,
         account: Account?,
         loaded: Loaded,
         filters: TransactionFilters,
         accounts: List<Account>,
         categories: List<Category>,
     ): TransactionsByAccountState.Content {
         val page = loaded.page
         val canLoadMore = page.rows.size > page.limit
         val visible = page.rows.take(page.limit)
         val currency = summaryCurrency(account, loaded.totals)
         // An account-scoped list is already restricted by the query; intersecting it with a
         // different account picked on the filter screen would silently show nothing.
         val effective = if (accountId != null) filters.copy(accountIds = emptySet()) else filters
 
         return TransactionsByAccountState.Content(
             accountId = accountId,
             accountName = account?.name.orEmpty(),
             groups = groupByDate(
                 rows = matchedRows(visible, effective),
                 accountNames = accounts.associate { it.id to it.name },
                 dateLabel = ::dateLabel,
             ),
             // A row only has to name its account where the list mixes several. Scoped to one, the
             // toolbar already says which, and repeating it on every row would be noise.
             showAccountOnRows = accountId == null,
             summary = buildSummary(loaded.totals, currency),
             query = filters.query,
             filters = chips(effective, accounts, categories),
             activeFilterCount = effective.activeCount,
             isFiltered = effective.isNarrowed,
             periodMode = loaded.period.mode,
             period = periodLabel(loaded.period),
             // The pager is the date window's source only while no explicit range is set — see
             // `resolveWindow`. Hiding it there is what keeps the two from competing.
             showPeriodPager = effective.dateRange == TransactionDateRange.FollowPeriod,
             // Paging forward past the current period would only ever show an empty list, so the
             // pager stops there; going back is always allowed.
             canGoToPreviousPeriod = loaded.period.mode != TransactionPeriodMode.AllTime,
             canGoToNextPeriod = loaded.period.mode != TransactionPeriodMode.AllTime &&
                 today !in loaded.period.window,
             canLoadMore = canLoadMore,
         )
     }
 
     private fun matchedRows(
         visible: List<CategorizedTransaction>,
         filters: TransactionFilters,
     ): List<CategorizedTransaction> {
         // Compile once, then test each row: the amount fields are parsed a single time for the
         // whole page rather than re-parsed per row.
         val matcher = filters.compile()
         val matched = visible.filter { matcher.matches(it) }
         // Already ordered by the query's (operationDate, operationAt, createdAt) DESC — reversing
         // the flat list is what "oldest first" means, inside each day as well as between days.
         return if (filters.sort == TransactionSort.Oldest) matched.reversed() else matched
     }
 
     private fun periodLabel(period: Period): TransactionPeriodUi = when (period.mode) {
         TransactionPeriodMode.Month -> TransactionPeriodUi.Month(
             monthNumber = period.anchor.month.number,
             year = period.anchor.year,
         )
         TransactionPeriodMode.Week -> {
             val week = period.anchor.isoWeek()
             TransactionPeriodUi.Week(
                 from = period.window.from ?: period.anchor,
                 to = period.window.to ?: period.anchor,
                 weekNumber = week.weekNumber,
                 weekYear = week.weekYear,
             )
         }
         TransactionPeriodMode.AllTime -> TransactionPeriodUi.AllTime
     }
 
     /**
      * Semantic, not textual: the wording of "Today" and of an absolute date is the screen's
      * business, so the ViewModel stays free of user-facing strings and of locale assumptions.
      */
     private fun dateLabel(date: LocalDate): TransactionDateUi {
         val today = today
         return when (date) {
             today -> TransactionDateUi.Today
             LocalDate.fromEpochDays(today.toEpochDays() - 1) -> TransactionDateUi.Yesterday
             else -> TransactionDateUi.Exact(date)
         }
     }
 
     /** One fetch: at most `limit + 1` rows, the extra one only signalling that more exist. */
     private data class Page(val rows: List<CategorizedTransaction>, val limit: Int)
 
     /** Everything one window produced: the page of rows and the period totals behind it. */
     private data class Loaded(
         val period: Period,
         val page: Page,
         val totals: List<TransactionTotal>,
     )
 
     /** The visible period: what the user picked, where they paged to, and the resulting bounds. */
     private data class Period(
         val mode: TransactionPeriodMode,
         val anchor: LocalDate,
         val dateRange: TransactionDateRange,
         val window: TransactionPeriodWindow,
     )
 }