diff --git a/app/src/main/java/com/bitcointxoko/gudariwallet/ui/HistoryScreen.kt b/app/src/main/java/com/bitcointxoko/gudariwallet/ui/HistoryScreen.kt index 6fcfbf2..8909d2c 100644 --- a/app/src/main/java/com/bitcointxoko/gudariwallet/ui/HistoryScreen.kt +++ b/app/src/main/java/com/bitcointxoko/gudariwallet/ui/HistoryScreen.kt @@ -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 diff --git a/app/src/main/java/com/bitcointxoko/gudariwallet/ui/HistoryViewModel.kt b/app/src/main/java/com/bitcointxoko/gudariwallet/ui/HistoryViewModel.kt index f0df4f2..de94215 100644 --- a/app/src/main/java/com/bitcointxoko/gudariwallet/ui/HistoryViewModel.kt +++ b/app/src/main/java/com/bitcointxoko/gudariwallet/ui/HistoryViewModel.kt @@ -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.Loading) val state: StateFlow = _state + // ── Filter state ────────────────────────────────────────────────────────── + + private val _filter = MutableStateFlow(PaymentFilter()) + val filter: StateFlow = _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 = 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 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(