diff --git a/lib/features/trades/providers/trades_providers.dart b/lib/features/trades/providers/trades_providers.dart index 308a804f..39c80fad 100644 --- a/lib/features/trades/providers/trades_providers.dart +++ b/lib/features/trades/providers/trades_providers.dart @@ -114,7 +114,10 @@ TradeStatusFilter orderStatusToFilter(rust_types.OrderStatus status) { // ── Internal helpers ────────────────────────────────────────────────────────── /// Converts a [rust_types.TradeInfo] to a [TradeListItem]. -TradeListItem _tradeInfoToItem(rust_types.TradeInfo trade) { +TradeListItem _tradeInfoToItem( + rust_types.TradeInfo trade, { + TradeStatusFilter? statusOverride, +}) { final fiatDisplay = _formatFiat( trade.order.fiatAmount, trade.order.fiatAmountMin, @@ -128,7 +131,7 @@ TradeListItem _tradeInfoToItem(rust_types.TradeInfo trade) { // TradeRole.buyer = the user is buying Bitcoin (took a sell order or // created a buy order). TradeRole.seller = selling Bitcoin. isSelling: trade.role == rust_types.TradeRole.seller, - status: orderStatusToFilter(trade.order.status), + status: statusOverride ?? orderStatusToFilter(trade.order.status), // order.isMine is true when the local user published this order (maker). role: trade.order.isMine ? TradeRole.creator : TradeRole.taker, fiatAmount: fiatDisplay, @@ -197,7 +200,26 @@ final filteredTradesWithOrderStateProvider = final filter = ref.watch(selectedStatusFilterProvider); final trades = await ref.watch(rawTradesProvider.future); - final items = trades.map(_tradeInfoToItem).toList(); + // Bucket each trade by the SAME live status its row chip shows, so the filter + // and the chip can never disagree (issue #269). The persisted snapshot in the + // DB can lag behind gift-wrap / 38383 updates (and, for own orders, is not + // always synced back to Pending), so tradeStatusProvider is the single source + // of truth. Until the live status has loaded we fall back to the snapshot + // bucket, so nothing briefly escapes the active filter. + final items = trades.map((trade) { + // Terminal trades cannot change further, so don't create a long-lived 2s + // poller for them — mirroring the guard in orderBookNotificationCountProvider + // (#299 review). Their snapshot status is authoritative here: order status + // only ever moves toward terminal, so a snapshot can lag but never run ahead, + // and falling back to it (live == null) buckets them by their real status. + final live = _terminalOrderStatuses.contains(trade.order.status) + ? null + : ref.watch(tradeStatusProvider(trade.order.id)).valueOrNull; + return _tradeInfoToItem( + trade, + statusOverride: live == null ? null : orderStatusToFilter(live), + ); + }).toList(); final filtered = filter == TradeStatusFilter.all ? items diff --git a/test/features/trades/filtered_trades_provider_test.dart b/test/features/trades/filtered_trades_provider_test.dart index e7bf4116..45243be2 100644 --- a/test/features/trades/filtered_trades_provider_test.dart +++ b/test/features/trades/filtered_trades_provider_test.dart @@ -1,6 +1,9 @@ +import 'dart:async'; + import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:mostro/features/trades/providers/trades_providers.dart'; +import 'package:mostro/features/order/providers/trade_state_provider.dart'; import 'package:mostro/src/rust/api/types.dart' show TradeInfo, OrderStatus; import '../../support/fake_trades.dart'; @@ -95,4 +98,120 @@ void main() { }); }); }); + + group('live status buckets the filter (issue #269)', () { + // A trade whose persisted snapshot is Pending, but whose live status (the + // one the row chip shows) has already moved to waitingBuyerInvoice. The + // filter must follow the live status, not the stale snapshot. + ProviderContainer staleSnapshotContainer() => createContainer(overrides: [ + rawTradesProvider.overrideWith( + (ref) async => [fakeTrade(id: 'x', status: OrderStatus.pending)], + ), + tradeStatusProvider('order-x').overrideWith( + (ref) => Stream.value(OrderStatus.waitingBuyerInvoice), + ), + ]); + + // Wait for the overridden tradeStatusProvider stream to emit its first + // value, so the derived filter provider sees the live status rather than + // racing the snapshot fallback (which only applies until live loads). + Future primeLiveStatus(ProviderContainer c) async { + await c.read(tradeStatusProvider('order-x').future); + } + + test('trade does NOT appear under the stale Pending bucket', () async { + final container = staleSnapshotContainer(); + await primeLiveStatus(container); + expect( + await _orderIds(container, filter: TradeStatusFilter.pending), + isEmpty, + ); + }); + + test('trade appears under the live Waiting Invoice bucket', () async { + final container = staleSnapshotContainer(); + await primeLiveStatus(container); + expect( + await _orderIds(container, filter: TradeStatusFilter.waitingInvoice), + ['order-x'], + ); + }); + + test('falls back to the snapshot bucket until live status loads', () async { + // No tradeStatusProvider override: live is unavailable, so the snapshot + // status (Pending) is used. This preserves behaviour before first poll. + final container = createContainer(overrides: [ + rawTradesProvider.overrideWith( + (ref) async => [fakeTrade(id: 'y', status: OrderStatus.pending)], + ), + tradeStatusProvider('order-y').overrideWith( + (ref) => const Stream.empty(), + ), + ]); + expect( + await _orderIds(container, filter: TradeStatusFilter.pending), + ['order-y'], + ); + }); + + test('re-buckets live when the status transitions', () async { + // The core promise of #269: as a trade's live status changes, it moves + // between filter buckets in step with its chip. Drive the live status + // with a controllable stream and assert the trade leaves the old bucket + // and enters the new one. + final controller = StreamController(); + addTearDown(controller.close); + final container = createContainer(overrides: [ + rawTradesProvider.overrideWith( + (ref) async => [fakeTrade(id: 'z', status: OrderStatus.pending)], + ), + tradeStatusProvider('order-z').overrideWith((ref) => controller.stream), + ]); + + // First live status: Pending. In the Pending bucket, not Waiting Invoice. + controller.add(OrderStatus.pending); + await container.read(tradeStatusProvider('order-z').future); + expect( + await _orderIds(container, filter: TradeStatusFilter.pending), + ['order-z'], + ); + expect( + await _orderIds(container, filter: TradeStatusFilter.waitingInvoice), + isEmpty, + ); + + // Transition to Waiting Invoice: leaves Pending, enters Waiting Invoice. + controller.add(OrderStatus.waitingBuyerInvoice); + await Future.delayed(Duration.zero); + expect( + await _orderIds(container, filter: TradeStatusFilter.pending), + isEmpty, + ); + expect( + await _orderIds(container, filter: TradeStatusFilter.waitingInvoice), + ['order-z'], + ); + }); + test( + 'a terminal trade keeps its snapshot bucket and ignores live status (#299)', + () async { + // A terminal trade must not spawn a live-status poller. Override its + // tradeStatusProvider with a NON-terminal live status: if the guard + // failed to skip the watch, the trade would follow that live status out + // of the success bucket. Because terminal trades bucket by snapshot, the + // override is never read, so it stays in success (#299 review). + final container = createContainer(overrides: [ + rawTradesProvider.overrideWith( + (ref) async => [fakeTrade(id: 'done', status: OrderStatus.success)], + ), + tradeStatusProvider('order-done').overrideWith( + (ref) => Stream.value(OrderStatus.pending), + ), + ]); + expect( + await _orderIds(container, filter: TradeStatusFilter.success), + ['order-done'], + ); + }); + }); }