feat: add filtering by direction

This commit is contained in:
2026-06-02 20:37:06 +02:00
parent 7ec3d13590
commit ecd01349ec
2 changed files with 232 additions and 123 deletions
@@ -13,10 +13,12 @@ import androidx.compose.material.icons.filled.ArrowDownward
import androidx.compose.material.icons.filled.ArrowUpward
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.ContentCopy
import androidx.compose.material.icons.filled.FilterList
import androidx.compose.material.icons.outlined.ContentCopy
import androidx.compose.material3.*
import androidx.compose.material3.pulltorefresh.PullToRefreshBox
import androidx.compose.runtime.*
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
@@ -43,11 +45,18 @@ fun HistoryScreen(
onClose: () -> Unit,
onPaymentClick: (PaymentRecord) -> Unit
) {
val state by vm.state.collectAsState()
val state by vm.filteredState.collectAsState()
val filter by vm.filter.collectAsState()
val fiatCurrency by vm.fiatCurrency.collectAsState()
val fiatSatsPerUnit by vm.fiatSatsPerUnit.collectAsState()
val listState = rememberLazyListState()
var filterSheetVisible by rememberSaveable { mutableStateOf(false) }
val dismissFilterSheet = { filterSheetVisible = false }
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
val isFiltered = filter.direction != DirectionFilter.ALL
Scaffold(
topBar = {
TopAppBar(
@@ -59,91 +68,139 @@ fun HistoryScreen(
contentDescription = "Close"
)
}
},
actions = {
// Badge the icon when a filter is active
BadgedBox(
badge = {
if (isFiltered) Badge()
}
) {
IconButton(onClick = { filterSheetVisible = true }) {
Icon(
imageVector = Icons.Default.FilterList,
contentDescription = "Filter payments"
)
}
}
}
)
}
) { padding ->
Box(
Column(
modifier = Modifier
.fillMaxSize()
.padding(padding)
) {
when (val s = state) {
is HistoryState.Loading -> {
CircularProgressIndicator(modifier = Modifier.align(Alignment.Center))
// ── Active filter chips ───────────────────────────────────────────
if (isFiltered) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 4.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
InputChip(
selected = true,
onClick = { vm.setDirectionFilter(DirectionFilter.ALL) },
label = {
Text(
when (filter.direction) {
DirectionFilter.OUTGOING -> "Outgoing"
else -> "Incoming"
}
)
},
trailingIcon = {
Icon(
imageVector = Icons.Default.Close,
contentDescription = "Clear direction filter",
modifier = Modifier.size(16.dp)
)
}
)
}
}
is HistoryState.Error -> {
Column(
modifier = Modifier.align(Alignment.Center),
horizontalAlignment = Alignment.CenterHorizontally
) {
Text(
text = s.message,
color = MaterialTheme.colorScheme.error,
style = MaterialTheme.typography.bodyMedium
)
Spacer(Modifier.height(12.dp))
Button(onClick = { vm.refresh() }) { Text("Retry") }
// ── Content ───────────────────────────────────────────────────────
Box(modifier = Modifier.fillMaxSize()) {
when (val s = state) {
is HistoryState.Loading -> {
CircularProgressIndicator(modifier = Modifier.align(Alignment.Center))
}
}
is HistoryState.Success -> {
val shouldLoadMore by remember {
derivedStateOf {
val lastVisible = listState.layoutInfo.visibleItemsInfo.lastOrNull()
val totalItems = listState.layoutInfo.totalItemsCount
lastVisible != null && lastVisible.index >= totalItems - 3
is HistoryState.Error -> {
Column(
modifier = Modifier.align(Alignment.Center),
horizontalAlignment = Alignment.CenterHorizontally
) {
Text(
text = s.message,
color = MaterialTheme.colorScheme.error,
style = MaterialTheme.typography.bodyMedium
)
Spacer(Modifier.height(12.dp))
Button(onClick = { vm.refresh() }) { Text("Retry") }
}
}
LaunchedEffect(shouldLoadMore) {
if (shouldLoadMore && s.canLoadMore) vm.loadMore()
}
if (s.payments.isEmpty()) {
Text(
text = "No payments yet.",
modifier = Modifier.align(Alignment.Center),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
} else PullToRefreshBox(
isRefreshing = s.isRefreshing,
onRefresh = { vm.refresh() }
) {
LazyColumn(
state = listState,
modifier = Modifier.fillMaxSize(),
contentPadding = PaddingValues(vertical = 8.dp)
) {
items(
items = s.payments,
key = { it.paymentHash }
) { payment ->
PaymentRow(
payment = payment,
fiatCurrency = fiatCurrency,
fiatSatsPerUnit = fiatSatsPerUnit,
onClick = { onPaymentClick(payment) }
)
HorizontalDivider(
modifier = Modifier.padding(horizontal = 16.dp),
thickness = 0.5.dp
)
}
if (s.canLoadMore) {
item {
Box(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
contentAlignment = Alignment.Center
) {
CircularProgressIndicator(
modifier = Modifier.size(24.dp),
strokeWidth = 2.dp
)
is HistoryState.Success -> {
val shouldLoadMore by remember {
derivedStateOf {
val lastVisible = listState.layoutInfo.visibleItemsInfo.lastOrNull()
val totalItems = listState.layoutInfo.totalItemsCount
lastVisible != null && lastVisible.index >= totalItems - 3
}
}
LaunchedEffect(shouldLoadMore) {
if (shouldLoadMore && s.canLoadMore) vm.loadMore()
}
if (s.payments.isEmpty()) {
Text(
text = "No payments yet.",
modifier = Modifier.align(Alignment.Center),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
} else PullToRefreshBox(
isRefreshing = s.isRefreshing,
onRefresh = { vm.refresh() }
) {
LazyColumn(
state = listState,
modifier = Modifier.fillMaxSize(),
contentPadding = PaddingValues(vertical = 8.dp)
) {
items(
items = s.payments,
key = { it.paymentHash }
) { payment ->
PaymentRow(
payment = payment,
fiatCurrency = fiatCurrency,
fiatSatsPerUnit = fiatSatsPerUnit,
onClick = { onPaymentClick(payment) }
)
HorizontalDivider(
modifier = Modifier.padding(horizontal = 16.dp),
thickness = 0.5.dp
)
}
if (s.canLoadMore) {
item {
Box(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
contentAlignment = Alignment.Center
) {
CircularProgressIndicator(
modifier = Modifier.size(24.dp),
strokeWidth = 2.dp
)
}
}
}
}
@@ -153,6 +210,70 @@ fun HistoryScreen(
}
}
}
// ── Filter bottom sheet ───────────────────────────────────────────────────
if (filterSheetVisible) {
FilterBottomSheet(
currentFilter = filter,
sheetState = sheetState,
onDismiss = dismissFilterSheet,
onDirectionSelected = { direction ->
vm.setDirectionFilter(direction)
dismissFilterSheet()
}
)
}
}
// ── Filter bottom sheet ───────────────────────────────────────────────────────
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun FilterBottomSheet(
currentFilter : PaymentFilter,
sheetState : SheetState,
onDismiss : () -> Unit,
onDirectionSelected: (DirectionFilter) -> Unit
) {
ModalBottomSheet(
onDismissRequest = onDismiss,
sheetState = sheetState
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(start = 24.dp, end = 24.dp, bottom = 32.dp)
) {
Text(
text = "Filter payments",
style = MaterialTheme.typography.titleMedium
)
Spacer(Modifier.height(20.dp))
Text(
text = "Direction",
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Spacer(Modifier.height(12.dp))
// Direction options as a segmented-style row of filter chips
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
DirectionFilter.entries.forEach { option ->
val label = when (option) {
DirectionFilter.ALL -> "All"
DirectionFilter.OUTGOING -> "Outgoing"
DirectionFilter.INCOMING -> "Incoming"
}
FilterChip(
selected = currentFilter.direction == option,
onClick = { onDirectionSelected(option) },
label = { Text(label) }
)
}
}
}
}
}
// ── Payment color helpers ─────────────────────────────────────────────────────
@@ -181,13 +302,11 @@ private fun PaymentRow(
) {
val semantic = semanticColors()
// ── Pending detection ────────────────────────────────────────────────────
val isPending = isPendingStatus(payment.status)
val amountColor = paymentAmountColor(payment.status, payment.isOutgoing)
val icon = if (payment.isOutgoing) Icons.Default.ArrowUpward
val isPending = isPendingStatus(payment.status)
val amountColor = paymentAmountColor(payment.status, payment.isOutgoing)
val icon = if (payment.isOutgoing) Icons.Default.ArrowUpward
else Icons.Default.ArrowDownward
// Fiat secondary line — only shown when rate is available
val fiatLine: String? = remember(payment.amountSat, fiatSatsPerUnit, fiatCurrency) {
if (fiatSatsPerUnit != null && fiatCurrency != null && payment.amountSat > 0)
formatFiatForSats(payment.amountSat, fiatSatsPerUnit, fiatCurrency)
@@ -201,7 +320,6 @@ private fun PaymentRow(
.padding(horizontal = 16.dp, vertical = 12.dp),
verticalAlignment = Alignment.CenterVertically
) {
// Direction icon — tinted to match amount color
Icon(
imageVector = icon,
contentDescription = if (payment.isOutgoing) "Sent" else "Received",
@@ -210,7 +328,6 @@ private fun PaymentRow(
)
Spacer(Modifier.width(12.dp))
// Left: memo + timestamp + optional pending badge
Column(modifier = Modifier.weight(1f)) {
Text(
text = payment.memo?.takeIf { it.isNotBlank() } ?: "No memo",
@@ -224,7 +341,6 @@ private fun PaymentRow(
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
// ── Pending badge ────────────────────────────────────────────────
if (isPending) {
Spacer(Modifier.height(4.dp))
Surface(
@@ -346,7 +462,6 @@ private fun PaymentDetailContent(
fiatSatsPerUnit : Double?, // ← new
modifier : Modifier = Modifier
) {
val isPending = isPendingStatus(payment.status)
val amountColor = paymentAmountColor(payment.status, payment.isOutgoing)
val context = LocalContext.current
@@ -12,10 +12,23 @@ import com.bitcointxoko.gudariwallet.service.WalletNotificationService
import com.bitcointxoko.gudariwallet.util.WalletConstants
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
// ── Filter model ──────────────────────────────────────────────────────────────
data class PaymentFilter(
val direction: DirectionFilter = DirectionFilter.ALL
)
enum class DirectionFilter { ALL, OUTGOING, INCOMING }
// ── ViewModel ─────────────────────────────────────────────────────────────────
class HistoryViewModel(
private val repo : WalletRepository,
private val aliasRepo : NodeAliasRepository,
@@ -27,12 +40,31 @@ class HistoryViewModel(
private val _state = MutableStateFlow<HistoryState>(HistoryState.Loading)
val state: StateFlow<HistoryState> = _state
// ── Filter state ──────────────────────────────────────────────────────────
private val _filter = MutableStateFlow(PaymentFilter())
val filter: StateFlow<PaymentFilter> = _filter
fun setDirectionFilter(direction: DirectionFilter) {
_filter.update { it.copy(direction = direction) }
}
/** The list shown in the UI — raw state with filter applied. */
val filteredState: StateFlow<HistoryState> = combine(_state, _filter) { s, f ->
if (s !is HistoryState.Success) return@combine s
val filtered = when (f.direction) {
DirectionFilter.ALL -> s.payments
DirectionFilter.OUTGOING -> s.payments.filter { it.isOutgoing }
DirectionFilter.INCOMING -> s.payments.filter { !it.isOutgoing }
}
s.copy(payments = filtered)
}.stateIn(viewModelScope, SharingStarted.Eagerly, HistoryState.Loading)
private var currentOffset = 0
private var loadJob: Job? = null
init {
viewModelScope.launch {
// Serve first page from DB immediately — no TTL, always valid
val cached = paymentCache.loadCachedPage(
offset = 0,
limit = WalletConstants.PAYMENTS_PAGE_SIZE
@@ -50,10 +82,6 @@ class HistoryViewModel(
}
syncNewPayments()
}
// ── Live WebSocket updates ────────────────────────────────────────────────
// The payment is already written to the DB by WalletNotificationService
// before this event is emitted. syncNewPayments() will find it immediately
// via containsPayment() on the first page and stop after merging it.
viewModelScope.launch {
WalletNotificationService.paymentEvents.collect { event ->
Log.d(TAG, "PAYMENTS [WS EVENT ] incoming event ${event.paymentHash} — refreshing list")
@@ -66,16 +94,6 @@ class HistoryViewModel(
}
}
// ── Sync: fetch pages from network until we hit a known checkingId ────────
/**
* Fetches pages from the server starting at offset 0, merging each into the
* DB, until a payment is found that already exists in the DB (meaning we've
* caught up), or the server returns a partial page (end of history).
*
* This fills any gap caused by payments that arrived while the app was
* offline, without re-fetching history we already have.
*/
private suspend fun syncNewPayments(maxPayments: Int = WalletConstants.PAYMENTS_SYNC_CHECKPOINT) {
var syncOffset = 0
var totalNew = 0
@@ -84,17 +102,13 @@ class HistoryViewModel(
Log.d(TAG, "PAYMENTS [SYNC START] scanning for new payments (cap=$maxPayments)")
while (true) {
// ── Checkpoint guard ──────────────────────────────────────────────────
if (totalNew >= maxPayments) {
Log.d(TAG, "PAYMENTS [SYNC CAP ] reached $maxPayments payment limit — stopping early")
break
}
val page = runCatching {
repo.getPayments(
offset = syncOffset,
limit = WalletConstants.PAYMENTS_PAGE_SIZE
)
repo.getPayments(offset = syncOffset, limit = WalletConstants.PAYMENTS_PAGE_SIZE)
}.getOrElse { e ->
val current = _state.value
if (current is HistoryState.Success) {
@@ -110,14 +124,9 @@ class HistoryViewModel(
pagesChecked++
val overlapIndex = page.indexOfFirst { paymentCache.containsPayment(it.checkingId) }
val newPayments = if (overlapIndex == -1) page
else page.subList(0, overlapIndex)
// Respect the cap — don't write more than allowed
val allowedNew = maxPayments - totalNew
val toMerge = if (newPayments.size > allowedNew) newPayments.subList(0, allowedNew)
else newPayments
val newPayments = if (overlapIndex == -1) page else page.subList(0, overlapIndex)
val allowedNew = maxPayments - totalNew
val toMerge = if (newPayments.size > allowedNew) newPayments.subList(0, allowedNew) else newPayments
if (toMerge.isNotEmpty()) {
paymentCache.mergePayments(toMerge)
@@ -137,7 +146,6 @@ class HistoryViewModel(
syncOffset += page.size
}
// Reload first page from DB to reflect any newly merged payments
val refreshed = paymentCache.loadCachedPage(
offset = 0,
limit = currentOffset.coerceAtLeast(WalletConstants.PAYMENTS_PAGE_SIZE)
@@ -151,8 +159,6 @@ class HistoryViewModel(
)
}
// ── Pull-to-refresh ───────────────────────────────────────────────────────
fun refresh() {
loadJob?.cancel()
currentOffset = 0
@@ -165,15 +171,12 @@ class HistoryViewModel(
viewModelScope.launch { syncNewPayments() }
}
// ── Load more (pagination) ────────────────────────────────────────────────
fun loadMore() {
if (loadJob?.isActive == true) return
val current = _state.value
if (current !is HistoryState.Success || !current.canLoadMore) return
loadJob = viewModelScope.launch {
// Try DB first — if we have a full page cached, serve it instantly
val fromDb = paymentCache.loadCachedPage(
offset = currentOffset,
limit = WalletConstants.PAYMENTS_PAGE_SIZE
@@ -190,7 +193,6 @@ class HistoryViewModel(
return@launch
}
// Partial or empty DB page — fetch from network and merge
Log.d(TAG, "PAYMENTS [MORE NET ] offset=$currentOffset — DB has ${fromDb.size}, fetching from network")
runCatching {
repo.getPayments(offset = currentOffset, limit = WalletConstants.PAYMENTS_PAGE_SIZE)
@@ -216,14 +218,11 @@ class HistoryViewModel(
val detailState: StateFlow<DetailState> = _detailState
fun loadDetail(checkingId: String, record: PaymentRecord? = null) {
// Tier 1: in-memory cache
paymentCache.getCachedDetail(checkingId)?.let {
Log.d(TAG, "PAYMENTS [DETAIL HIT] $checkingId — instant from cache")
_detailState.value = DetailState.Success(it)
return
}
// Show partial UI immediately
if (record != null) {
Log.d(TAG, "PAYMENTS [DETAIL PAR] $checkingId — showing partial from PaymentRecord")
_detailState.value = DetailState.Partial(record)
@@ -231,9 +230,7 @@ class HistoryViewModel(
Log.d(TAG, "PAYMENTS [DETAIL LOD] $checkingId — no record, showing spinner")
_detailState.value = DetailState.Loading
}
viewModelScope.launch {
// Tier 2: Room DB
val fromDb = paymentCache.getDetailFromDb(checkingId)
if (fromDb != null) {
paymentCache.saveDetail(checkingId, fromDb)
@@ -241,8 +238,6 @@ class HistoryViewModel(
Log.d(TAG, "PAYMENTS [DETAIL DB ] $checkingId — served from Room, skipping network")
return@launch
}
// Tier 3: network
runCatching {
repo.getPaymentDetail(checkingId)
}.onSuccess { detail ->
@@ -283,7 +278,6 @@ class HistoryViewModel(
}
}
// ── Factory ───────────────────────────────────────────────────────────────────
class HistoryViewModelFactory(