Coverage Summary for Class: TransactionsByAccountScreenKt (com.georgeci.moneysurfer.feature.transaction.list)
| Class |
Method, %
|
Branch, %
|
Line, %
|
Instruction, %
|
| TransactionsByAccountScreenKt |
68.2%
(15/22)
|
24.5%
(27/110)
|
73%
(92/126)
|
59.2%
(995/1682)
|
| TransactionsByAccountScreenKt$LoadMoreOnScrollToEnd$1$1 |
100%
(1/1)
|
50%
(1/2)
|
100%
(1/1)
|
50%
(4/8)
|
| Total |
69.6%
(16/23)
|
25%
(28/112)
|
73.2%
(93/127)
|
59.1%
(999/1690)
|
package com.georgeci.moneysurfer.feature.transaction.list
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.material3.Scaffold
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.georgeci.moneysurfer.domain.primitives.AccountId
import com.georgeci.moneysurfer.domain.primitives.TransactionId
import com.georgeci.moneysurfer.uikit.components.base.SurferAddFab
import com.georgeci.moneysurfer.uikit.components.base.SurferToolbar
import com.georgeci.moneysurfer.uikit.modifier.surferContentContainer
import com.georgeci.moneysurfer.uikit.modifier.surferSafeInsets
import com.georgeci.moneysurfer.uikit.modifier.surferTestTagAsId
import com.georgeci.moneysurfer.uikit.theme.AppTheme
import com.georgeci.moneysurfer.utils.HandleSideEffect
import moneysurfer.feature.transaction.generated.resources.Res
import moneysurfer.feature.transaction.generated.resources.transactions_list_new
import moneysurfer.feature.transaction.generated.resources.transactions_list_title
import moneysurfer.feature.transaction.generated.resources.transactions_list_untitled
import org.jetbrains.compose.resources.stringResource
import org.koin.compose.viewmodel.koinViewModel
import org.koin.core.parameter.parametersOf
/**
* Stable selectors for the transactions list screen — see docs/testing/testing-strategy.md.
*
* The `filter:*` tags name the chips of the rail, not filter *values*: every chip is a shortcut
* that opens the filters screen rather than a segment that toggles a value on this screen.
*/
object TransactionsListTestTags {
const val Root = "transactionsList:root"
const val FilterDate = "transactionsList:filter:date"
const val FilterType = "transactionsList:filter:type"
const val FilterAccount = "transactionsList:filter:account"
const val FilterCategory = "transactionsList:filter:category"
const val FilterSort = "transactionsList:filter:sort"
}
@Composable
fun TransactionsByAccountScreen(
accountId: AccountId?,
onNavigateBack: () -> Unit,
onNavigateToTransactionCreation: (AccountId?) -> Unit,
onNavigateToTransactionDetails: (TransactionId) -> Unit,
onNavigateToFilters: (AccountId?, Long) -> Unit,
viewModel: TransactionsByAccountViewModel = koinViewModel(
key = accountId?.value.orEmpty(),
) { parametersOf(accountId) },
) {
val state by viewModel.collectAsStateWithLifecycle()
viewModel.HandleSideEffect { effect ->
when (effect) {
TransactionsByAccountEffect.NavigateBack -> onNavigateBack()
is TransactionsByAccountEffect.NavigateToTransactionCreation ->
onNavigateToTransactionCreation(effect.accountId)
is TransactionsByAccountEffect.NavigateToTransactionDetails ->
onNavigateToTransactionDetails(effect.transactionId)
is TransactionsByAccountEffect.NavigateToFilters ->
onNavigateToFilters(effect.accountId, effect.anchorEpochDay)
}
}
when (val current = state) {
is TransactionsByAccountState.Loading -> TransactionsByAccountLoading(
onEvent = viewModel::onEvent,
)
is TransactionsByAccountState.Content -> TransactionsByAccountContent(
state = current,
onEvent = viewModel::onEvent,
)
}
}
@Composable
private fun TransactionsByAccountLoading(onEvent: (TransactionsByAccountEvent) -> Unit) {
val titleFallback = stringResource(Res.string.transactions_list_title)
Scaffold(
modifier = Modifier.surferSafeInsets(),
containerColor = AppTheme.materialColors.surface,
topBar = {
SurferToolbar(
title = titleFallback,
onBack = { onEvent(TransactionsByAccountEvent.OnBackClick) },
)
},
) { padding ->
Box(modifier = Modifier.fillMaxSize().padding(padding))
}
}
/**
* The list, drawn from a state it is handed rather than one it resolves.
*
* Public for the desktop UI tests in `:composeApp` `jvmTest`, which mount it with a state of their
* own — see docs/testing/testing-strategy.md.
*/
@Composable
fun TransactionsByAccountContent(
state: TransactionsByAccountState.Content,
onEvent: (TransactionsByAccountEvent) -> Unit,
) {
val titleFallback = stringResource(Res.string.transactions_list_title)
val title = state.accountName.ifBlank { titleFallback }
Scaffold(
modifier = Modifier
.surferSafeInsets()
.testTag(TransactionsListTestTags.Root)
.surferTestTagAsId(),
containerColor = AppTheme.materialColors.surface,
topBar = {
SurferToolbar(
title = title,
onBack = { onEvent(TransactionsByAccountEvent.OnBackClick) },
)
},
floatingActionButton = {
// Not while the empty state is showing its own "Add transaction" CTA — see
// `showsAddCta`.
if (!state.showsAddCta) {
SurferAddFab(
label = stringResource(Res.string.transactions_list_new),
onClick = { onEvent(TransactionsByAccountEvent.OnAddTransactionClick) },
)
}
},
) { padding ->
Column(
modifier = Modifier
.fillMaxSize()
.surferContentContainer()
.padding(top = padding.calculateTopPadding()),
) {
SearchRow(
query = state.query,
activeFilterCount = state.activeFilterCount,
onEvent = onEvent,
)
Spacer(Modifier.height(8.dp))
// Only while the pager still owns the date window — an explicit range from the filter
// screen takes it over, and two controls over one window is exactly the trap the
// filters were supposed to avoid.
if (state.showPeriodPager) {
PeriodPager(
state = state,
onEvent = onEvent,
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 4.dp),
)
}
SummaryStrip(
summary = state.summary,
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 8.dp),
)
FilterChipRail(
chips = state.filters,
onOpenFilters = { onEvent(TransactionsByAccountEvent.OnOpenFiltersClick) },
)
Spacer(Modifier.height(4.dp))
if (state.isEmpty) {
EmptyState(state = state, onEvent = onEvent)
return@Scaffold
}
TransactionsList(
state = state,
onEvent = onEvent,
bottomPadding = padding.calculateBottomPadding(),
)
}
}
}
@Composable
private fun TransactionsList(
state: TransactionsByAccountState.Content,
onEvent: (TransactionsByAccountEvent) -> Unit,
bottomPadding: Dp,
) {
val untitled = stringResource(Res.string.transactions_list_untitled)
val listState = rememberLazyListState()
LoadMoreOnScrollToEnd(
listState = listState,
enabled = state.canLoadMore,
onLoadMore = { onEvent(TransactionsByAccountEvent.OnLoadMore) },
)
LazyColumn(
state = listState,
modifier = Modifier.fillMaxSize(),
contentPadding = PaddingValues(bottom = bottomPadding),
) {
state.groups.forEach { group ->
item(key = "h-${group.date}") {
DateHeader(group = group)
}
group.transactions.forEach { row ->
item(key = "t-${row.id.value}") {
TransactionRow(
row = row,
showAccount = state.showAccountOnRows,
untitled = untitled,
onClick = { onEvent(TransactionsByAccountEvent.OnTransactionClick(row.id)) },
onDelete = { onEvent(TransactionsByAccountEvent.OnDeleteTransaction(row.id)) },
)
}
}
}
}
}
/**
* Asks for the next page once the list is within [LOAD_MORE_THRESHOLD] rows of the end, so the
* fetch overlaps the remaining scroll instead of stalling at the bottom.
*/
@Composable
private fun LoadMoreOnScrollToEnd(
listState: LazyListState,
enabled: Boolean,
onLoadMore: () -> Unit,
) {
val shouldLoadMore by remember(enabled) {
derivedStateOf {
val layoutInfo = listState.layoutInfo
// Guard the pre-measurement frame: before the list is laid out, totalItemsCount is 0
// and there are no visible items, which would otherwise satisfy `0 >= 0 - threshold`
// and fire a page load with no scroll behind it.
val lastVisible = layoutInfo.visibleItemsInfo.lastOrNull()?.index
enabled && lastVisible != null &&
lastVisible >= layoutInfo.totalItemsCount - LOAD_MORE_THRESHOLD
}
}
LaunchedEffect(shouldLoadMore) {
if (shouldLoadMore) onLoadMore()
}
}
private const val LOAD_MORE_THRESHOLD = 10