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.ArrowUpward
import androidx.compose.material.icons.filled.Close import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.ContentCopy import androidx.compose.material.icons.filled.ContentCopy
import androidx.compose.material.icons.filled.FilterList
import androidx.compose.material.icons.outlined.ContentCopy import androidx.compose.material.icons.outlined.ContentCopy
import androidx.compose.material3.* import androidx.compose.material3.*
import androidx.compose.material3.pulltorefresh.PullToRefreshBox import androidx.compose.material3.pulltorefresh.PullToRefreshBox
import androidx.compose.runtime.* import androidx.compose.runtime.*
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
@@ -43,11 +45,18 @@ fun HistoryScreen(
onClose: () -> Unit, onClose: () -> Unit,
onPaymentClick: (PaymentRecord) -> 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 fiatCurrency by vm.fiatCurrency.collectAsState()
val fiatSatsPerUnit by vm.fiatSatsPerUnit.collectAsState() val fiatSatsPerUnit by vm.fiatSatsPerUnit.collectAsState()
val listState = rememberLazyListState() 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( Scaffold(
topBar = { topBar = {
TopAppBar( TopAppBar(
@@ -59,15 +68,62 @@ fun HistoryScreen(
contentDescription = "Close" 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 -> ) { padding ->
Box( Column(
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
.padding(padding) .padding(padding)
) { ) {
// ── 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)
)
}
)
}
}
// ── Content ───────────────────────────────────────────────────────
Box(modifier = Modifier.fillMaxSize()) {
when (val s = state) { when (val s = state) {
is HistoryState.Loading -> { is HistoryState.Loading -> {
@@ -153,6 +209,71 @@ 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 ───────────────────────────────────────────────────── // ── Payment color helpers ─────────────────────────────────────────────────────
@@ -181,13 +302,11 @@ private fun PaymentRow(
) { ) {
val semantic = semanticColors() val semantic = semanticColors()
// ── Pending detection ────────────────────────────────────────────────────
val isPending = isPendingStatus(payment.status) val isPending = isPendingStatus(payment.status)
val amountColor = paymentAmountColor(payment.status, payment.isOutgoing) val amountColor = paymentAmountColor(payment.status, payment.isOutgoing)
val icon = if (payment.isOutgoing) Icons.Default.ArrowUpward val icon = if (payment.isOutgoing) Icons.Default.ArrowUpward
else Icons.Default.ArrowDownward else Icons.Default.ArrowDownward
// Fiat secondary line — only shown when rate is available
val fiatLine: String? = remember(payment.amountSat, fiatSatsPerUnit, fiatCurrency) { val fiatLine: String? = remember(payment.amountSat, fiatSatsPerUnit, fiatCurrency) {
if (fiatSatsPerUnit != null && fiatCurrency != null && payment.amountSat > 0) if (fiatSatsPerUnit != null && fiatCurrency != null && payment.amountSat > 0)
formatFiatForSats(payment.amountSat, fiatSatsPerUnit, fiatCurrency) formatFiatForSats(payment.amountSat, fiatSatsPerUnit, fiatCurrency)
@@ -201,7 +320,6 @@ private fun PaymentRow(
.padding(horizontal = 16.dp, vertical = 12.dp), .padding(horizontal = 16.dp, vertical = 12.dp),
verticalAlignment = Alignment.CenterVertically verticalAlignment = Alignment.CenterVertically
) { ) {
// Direction icon — tinted to match amount color
Icon( Icon(
imageVector = icon, imageVector = icon,
contentDescription = if (payment.isOutgoing) "Sent" else "Received", contentDescription = if (payment.isOutgoing) "Sent" else "Received",
@@ -210,7 +328,6 @@ private fun PaymentRow(
) )
Spacer(Modifier.width(12.dp)) Spacer(Modifier.width(12.dp))
// Left: memo + timestamp + optional pending badge
Column(modifier = Modifier.weight(1f)) { Column(modifier = Modifier.weight(1f)) {
Text( Text(
text = payment.memo?.takeIf { it.isNotBlank() } ?: "No memo", text = payment.memo?.takeIf { it.isNotBlank() } ?: "No memo",
@@ -224,7 +341,6 @@ private fun PaymentRow(
style = MaterialTheme.typography.bodySmall, style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant color = MaterialTheme.colorScheme.onSurfaceVariant
) )
// ── Pending badge ────────────────────────────────────────────────
if (isPending) { if (isPending) {
Spacer(Modifier.height(4.dp)) Spacer(Modifier.height(4.dp))
Surface( Surface(
@@ -346,7 +462,6 @@ private fun PaymentDetailContent(
fiatSatsPerUnit : Double?, // ← new fiatSatsPerUnit : Double?, // ← new
modifier : Modifier = Modifier modifier : Modifier = Modifier
) { ) {
val isPending = isPendingStatus(payment.status)
val amountColor = paymentAmountColor(payment.status, payment.isOutgoing) val amountColor = paymentAmountColor(payment.status, payment.isOutgoing)
val context = LocalContext.current val context = LocalContext.current
@@ -12,10 +12,23 @@ import com.bitcointxoko.gudariwallet.service.WalletNotificationService
import com.bitcointxoko.gudariwallet.util.WalletConstants import com.bitcointxoko.gudariwallet.util.WalletConstants
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.update import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
// ── Filter model ──────────────────────────────────────────────────────────────
data class PaymentFilter(
val direction: DirectionFilter = DirectionFilter.ALL
)
enum class DirectionFilter { ALL, OUTGOING, INCOMING }
// ── ViewModel ─────────────────────────────────────────────────────────────────
class HistoryViewModel( class HistoryViewModel(
private val repo : WalletRepository, private val repo : WalletRepository,
private val aliasRepo : NodeAliasRepository, private val aliasRepo : NodeAliasRepository,
@@ -27,12 +40,31 @@ class HistoryViewModel(
private val _state = MutableStateFlow<HistoryState>(HistoryState.Loading) private val _state = MutableStateFlow<HistoryState>(HistoryState.Loading)
val state: StateFlow<HistoryState> = _state 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 currentOffset = 0
private var loadJob: Job? = null private var loadJob: Job? = null
init { init {
viewModelScope.launch { viewModelScope.launch {
// Serve first page from DB immediately — no TTL, always valid
val cached = paymentCache.loadCachedPage( val cached = paymentCache.loadCachedPage(
offset = 0, offset = 0,
limit = WalletConstants.PAYMENTS_PAGE_SIZE limit = WalletConstants.PAYMENTS_PAGE_SIZE
@@ -50,10 +82,6 @@ class HistoryViewModel(
} }
syncNewPayments() 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 { viewModelScope.launch {
WalletNotificationService.paymentEvents.collect { event -> WalletNotificationService.paymentEvents.collect { event ->
Log.d(TAG, "PAYMENTS [WS EVENT ] incoming event ${event.paymentHash} — refreshing list") 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) { private suspend fun syncNewPayments(maxPayments: Int = WalletConstants.PAYMENTS_SYNC_CHECKPOINT) {
var syncOffset = 0 var syncOffset = 0
var totalNew = 0 var totalNew = 0
@@ -84,17 +102,13 @@ class HistoryViewModel(
Log.d(TAG, "PAYMENTS [SYNC START] scanning for new payments (cap=$maxPayments)") Log.d(TAG, "PAYMENTS [SYNC START] scanning for new payments (cap=$maxPayments)")
while (true) { while (true) {
// ── Checkpoint guard ──────────────────────────────────────────────────
if (totalNew >= maxPayments) { if (totalNew >= maxPayments) {
Log.d(TAG, "PAYMENTS [SYNC CAP ] reached $maxPayments payment limit — stopping early") Log.d(TAG, "PAYMENTS [SYNC CAP ] reached $maxPayments payment limit — stopping early")
break break
} }
val page = runCatching { val page = runCatching {
repo.getPayments( repo.getPayments(offset = syncOffset, limit = WalletConstants.PAYMENTS_PAGE_SIZE)
offset = syncOffset,
limit = WalletConstants.PAYMENTS_PAGE_SIZE
)
}.getOrElse { e -> }.getOrElse { e ->
val current = _state.value val current = _state.value
if (current is HistoryState.Success) { if (current is HistoryState.Success) {
@@ -110,14 +124,9 @@ class HistoryViewModel(
pagesChecked++ pagesChecked++
val overlapIndex = page.indexOfFirst { paymentCache.containsPayment(it.checkingId) } val overlapIndex = page.indexOfFirst { paymentCache.containsPayment(it.checkingId) }
val newPayments = if (overlapIndex == -1) page else page.subList(0, overlapIndex)
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 allowedNew = maxPayments - totalNew
val toMerge = if (newPayments.size > allowedNew) newPayments.subList(0, allowedNew) val toMerge = if (newPayments.size > allowedNew) newPayments.subList(0, allowedNew) else newPayments
else newPayments
if (toMerge.isNotEmpty()) { if (toMerge.isNotEmpty()) {
paymentCache.mergePayments(toMerge) paymentCache.mergePayments(toMerge)
@@ -137,7 +146,6 @@ class HistoryViewModel(
syncOffset += page.size syncOffset += page.size
} }
// Reload first page from DB to reflect any newly merged payments
val refreshed = paymentCache.loadCachedPage( val refreshed = paymentCache.loadCachedPage(
offset = 0, offset = 0,
limit = currentOffset.coerceAtLeast(WalletConstants.PAYMENTS_PAGE_SIZE) limit = currentOffset.coerceAtLeast(WalletConstants.PAYMENTS_PAGE_SIZE)
@@ -151,8 +159,6 @@ class HistoryViewModel(
) )
} }
// ── Pull-to-refresh ───────────────────────────────────────────────────────
fun refresh() { fun refresh() {
loadJob?.cancel() loadJob?.cancel()
currentOffset = 0 currentOffset = 0
@@ -165,15 +171,12 @@ class HistoryViewModel(
viewModelScope.launch { syncNewPayments() } viewModelScope.launch { syncNewPayments() }
} }
// ── Load more (pagination) ────────────────────────────────────────────────
fun loadMore() { fun loadMore() {
if (loadJob?.isActive == true) return if (loadJob?.isActive == true) return
val current = _state.value val current = _state.value
if (current !is HistoryState.Success || !current.canLoadMore) return if (current !is HistoryState.Success || !current.canLoadMore) return
loadJob = viewModelScope.launch { loadJob = viewModelScope.launch {
// Try DB first — if we have a full page cached, serve it instantly
val fromDb = paymentCache.loadCachedPage( val fromDb = paymentCache.loadCachedPage(
offset = currentOffset, offset = currentOffset,
limit = WalletConstants.PAYMENTS_PAGE_SIZE limit = WalletConstants.PAYMENTS_PAGE_SIZE
@@ -190,7 +193,6 @@ class HistoryViewModel(
return@launch 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") Log.d(TAG, "PAYMENTS [MORE NET ] offset=$currentOffset — DB has ${fromDb.size}, fetching from network")
runCatching { runCatching {
repo.getPayments(offset = currentOffset, limit = WalletConstants.PAYMENTS_PAGE_SIZE) repo.getPayments(offset = currentOffset, limit = WalletConstants.PAYMENTS_PAGE_SIZE)
@@ -216,14 +218,11 @@ class HistoryViewModel(
val detailState: StateFlow<DetailState> = _detailState val detailState: StateFlow<DetailState> = _detailState
fun loadDetail(checkingId: String, record: PaymentRecord? = null) { fun loadDetail(checkingId: String, record: PaymentRecord? = null) {
// Tier 1: in-memory cache
paymentCache.getCachedDetail(checkingId)?.let { paymentCache.getCachedDetail(checkingId)?.let {
Log.d(TAG, "PAYMENTS [DETAIL HIT] $checkingId — instant from cache") Log.d(TAG, "PAYMENTS [DETAIL HIT] $checkingId — instant from cache")
_detailState.value = DetailState.Success(it) _detailState.value = DetailState.Success(it)
return return
} }
// Show partial UI immediately
if (record != null) { if (record != null) {
Log.d(TAG, "PAYMENTS [DETAIL PAR] $checkingId — showing partial from PaymentRecord") Log.d(TAG, "PAYMENTS [DETAIL PAR] $checkingId — showing partial from PaymentRecord")
_detailState.value = DetailState.Partial(record) _detailState.value = DetailState.Partial(record)
@@ -231,9 +230,7 @@ class HistoryViewModel(
Log.d(TAG, "PAYMENTS [DETAIL LOD] $checkingId — no record, showing spinner") Log.d(TAG, "PAYMENTS [DETAIL LOD] $checkingId — no record, showing spinner")
_detailState.value = DetailState.Loading _detailState.value = DetailState.Loading
} }
viewModelScope.launch { viewModelScope.launch {
// Tier 2: Room DB
val fromDb = paymentCache.getDetailFromDb(checkingId) val fromDb = paymentCache.getDetailFromDb(checkingId)
if (fromDb != null) { if (fromDb != null) {
paymentCache.saveDetail(checkingId, fromDb) paymentCache.saveDetail(checkingId, fromDb)
@@ -241,8 +238,6 @@ class HistoryViewModel(
Log.d(TAG, "PAYMENTS [DETAIL DB ] $checkingId — served from Room, skipping network") Log.d(TAG, "PAYMENTS [DETAIL DB ] $checkingId — served from Room, skipping network")
return@launch return@launch
} }
// Tier 3: network
runCatching { runCatching {
repo.getPaymentDetail(checkingId) repo.getPaymentDetail(checkingId)
}.onSuccess { detail -> }.onSuccess { detail ->
@@ -283,7 +278,6 @@ class HistoryViewModel(
} }
} }
// ── Factory ─────────────────────────────────────────────────────────────────── // ── Factory ───────────────────────────────────────────────────────────────────
class HistoryViewModelFactory( class HistoryViewModelFactory(