diff --git a/lib/features/chat/widgets/trade_state_header.dart b/lib/features/chat/widgets/trade_state_header.dart index e9021ab2..338d05ec 100644 --- a/lib/features/chat/widgets/trade_state_header.dart +++ b/lib/features/chat/widgets/trade_state_header.dart @@ -8,8 +8,11 @@ import 'package:mostro/core/app_routes.dart'; import 'package:mostro/core/app_theme.dart'; import 'package:mostro/features/home/providers/home_order_providers.dart'; import 'package:mostro/features/order/providers/trade_state_provider.dart'; +import 'package:mostro/features/about/providers/mostro_node_provider.dart'; +import 'package:mostro/features/order/utils/waiting_countdown.dart'; import 'package:mostro/features/trades/providers/trades_providers.dart'; import 'package:mostro/l10n/app_localizations.dart'; +import 'package:mostro/shared/utils/platform_int64.dart'; import 'package:mostro/shared/widgets/status_chip.dart'; import 'package:mostro/src/rust/api/orders.dart' as orders_api; import 'package:mostro/src/rust/api/types.dart' as rust_types; @@ -89,6 +92,25 @@ class TradeStateHeader extends ConsumerWidget { // Live status overrides the snapshot baked into the resolved order. final liveStatus = ref.watch(tradeStatusProvider(orderId)).valueOrNull; final statusFilter = orderStatusToFilter(liveStatus ?? order.status); + // #270: base the countdown on the waiting-state deadline (timeout_at or + // now + node expiration_seconds), not the 24 h pending expiry. Shared with + // the trade-detail screen so both surfaces show the same value. + final tradeInfo = ref.watch(tradeInfoProvider(orderId)).valueOrNull; + final expirationSeconds = + ref.watch(mostroNodeProvider).valueOrNull?.expirationSeconds; + final countdown = waitingCountdownDeadline( + // Fall back to the order snapshot's status while tradeStatusProvider + // resolves, mirroring the pill above — otherwise a known waiting/pending + // order shows no countdown during the provider's loading frame. + status: liveStatus ?? order.status, + pendingExpiresAt: order.expiresAt, + pendingCreatedAtEpoch: + order.createdAt.millisecondsSinceEpoch ~/ 1000, + timeoutAtEpoch: tradeInfo?.timeoutAt != null + ? platformInt64ToInt(tradeInfo!.timeoutAt!) + : null, + expirationSeconds: expirationSeconds, + ); final (pillBg, pillFg) = _statusColors(statusFilter); // Buyer/seller role: in-memory map → persisted DB → derive from order. @@ -171,9 +193,11 @@ class TradeStateHeader extends ConsumerWidget { ), ), ], - if (order.expiresAt != null) + if (countdown != null) _CountdownChip( - expiresAt: order.expiresAt!, + expiresAt: DateTime.fromMillisecondsSinceEpoch( + countdown.deadlineEpochSeconds * 1000, + ), color: amber, separatorColor: secondary, showSeparator: order.paymentMethod.isNotEmpty, diff --git a/lib/features/order/utils/waiting_countdown.dart b/lib/features/order/utils/waiting_countdown.dart new file mode 100644 index 00000000..0c9f37ea --- /dev/null +++ b/lib/features/order/utils/waiting_countdown.dart @@ -0,0 +1,64 @@ +import 'package:mostro/src/rust/api/types.dart'; + +/// The countdown target for a trade, resolved for #270. +/// +/// [deadlineEpochSeconds] is the unix-second instant the countdown runs to; +/// [totalWindowSeconds] sizes the progress ring (the full window). +typedef CountdownDeadline = ({int deadlineEpochSeconds, int totalWindowSeconds}); + +/// Last-resort waiting-state window (seconds) when the node's instance event +/// omits `expiration_seconds`. Matches the Mostro daemon default (15 min). +const int kWaitingCountdownFallbackSeconds = 900; + +/// Chooses the countdown target for a trade (#270): +/// +/// - **Pending**: counts to the 24 h pending-order expiry ([pendingExpiresAt]). +/// - **Waiting states** (buyer-invoice / payment): counts to the trade's +/// [timeoutAtEpoch] when one is present, else **no countdown** (null). +/// There is deliberately no `startedAt`-based fallback: `startedAt` is the +/// order-creation time, so for a maker whose order sat in the book before +/// being taken it yields a deadline already in the past — a countdown born at +/// zero. "No anchor, no countdown" is correct until the daemon stamps +/// `timeout_at` on waiting-state entry from the node's `expiration_seconds` +/// (a follow-up daemon change; #306 review). Today `timeout_at` is a fixed +/// 900 written client-side on take, not persisted by the daemon. +/// - **Any other state**: no countdown (null). +/// +/// The helper is pure — no clock read, no cache — so its result is stable across +/// the per-second rebuilds that drive the ticking UI. +/// +/// The UI only informs — the daemon stays the authority on expiry, so callers +/// never cancel locally at zero. +CountdownDeadline? waitingCountdownDeadline({ + required OrderStatus? status, + DateTime? pendingExpiresAt, + int? pendingCreatedAtEpoch, + int? timeoutAtEpoch, + int? expirationSeconds, +}) { + final window = expirationSeconds ?? kWaitingCountdownFallbackSeconds; + switch (status) { + case OrderStatus.pending: + if (pendingExpiresAt == null) return null; + final deadline = pendingExpiresAt.millisecondsSinceEpoch ~/ 1000; + // The ring spans the whole pending window (creation -> 24 h expiry), not + // the waiting window, so the progress ring is meaningful (#306 review). + final total = + (pendingCreatedAtEpoch != null && deadline > pendingCreatedAtEpoch) + ? deadline - pendingCreatedAtEpoch + : window; + return (deadlineEpochSeconds: deadline, totalWindowSeconds: total); + case OrderStatus.waitingBuyerInvoice: + case OrderStatus.waitingPayment: + // Only count down when timeout_at is present. The startedAt fallback + // produced a past deadline for makers (startedAt is creation time), so + // "no anchor, no countdown" until the daemon stamps timeout_at properly + // (#306 review). + if (timeoutAtEpoch != null) { + return (deadlineEpochSeconds: timeoutAtEpoch, totalWindowSeconds: window); + } + return null; + default: + return null; + } +} diff --git a/lib/features/trades/screens/trade_detail_screen.dart b/lib/features/trades/screens/trade_detail_screen.dart index 39c8e679..0551ed5a 100644 --- a/lib/features/trades/screens/trade_detail_screen.dart +++ b/lib/features/trades/screens/trade_detail_screen.dart @@ -18,6 +18,8 @@ import 'package:mostro/features/chat/providers/chat_providers.dart'; import 'package:mostro/features/disputes/providers/disputes_providers.dart'; import 'package:mostro/features/home/providers/home_order_providers.dart'; import 'package:mostro/features/order/providers/trade_state_provider.dart'; +import 'package:mostro/features/about/providers/mostro_node_provider.dart'; +import 'package:mostro/features/order/utils/waiting_countdown.dart'; import 'package:mostro/features/rate/providers/rating_providers.dart'; import 'package:mostro/features/trades/providers/trades_providers.dart'; import 'package:mostro/features/trades/widgets/dispute_confirmation_dialog.dart'; @@ -42,7 +44,9 @@ class TradeDetailScreen extends ConsumerStatefulWidget { ConsumerState createState() => _TradeDetailScreenState(); } -/// Default trade countdown duration (matches Mostro daemon default). +/// Last-resort waiting-state countdown fallback, used only when the node's +/// instance event omits `expiration_seconds` (#270). The real window comes from +/// `MostroInstance.expirationSeconds` and the real deadline from `timeoutAt`. const _kCountdownSeconds = 900; // 15 minutes /// Type-safe trade status for the detail screen. @@ -125,7 +129,8 @@ class _TradeDetailScreenState extends ConsumerState { @override void initState() { super.initState(); - _loadExpiresAt(); + // #270: the deadline is fed reactively from build() once tradeInfoProvider + // (timeoutAt) and mostroNodeProvider (expiration_seconds) resolve. _startCountdown(); } @@ -135,26 +140,33 @@ class _TradeDetailScreenState extends ConsumerState { super.dispose(); } - /// Fetches the real `expiresAt` from the order and resets [_remaining]. - /// - /// Falls back to the default [_kCountdownSeconds] when the field is null or - /// the order is no longer available. - Future _loadExpiresAt() async { - try { - final info = await orders_api.getOrder(orderId: widget.orderId); - final raw = info?.expiresAt; - if (raw == null || !mounted) return; - final expiresAtSeconds = platformInt64ToInt(raw); - final now = DateTime.now().millisecondsSinceEpoch ~/ 1000; - final diff = expiresAtSeconds - now; - if (!mounted) return; - setState(() { - _totalCountdownSeconds = diff > 0 ? diff : _kCountdownSeconds; - _remaining = diff > 0 ? Duration(seconds: diff) : Duration.zero; - }); - } catch (_) { - // Keep the default remaining time on error. - } + /// Applies a resolved countdown deadline (unix seconds) to the ticking timer, + /// sizing the progress ring off [totalSeconds]. Only resets when the target + /// actually changes, so the per-second tick isn't clobbered on every rebuild. + int? _appliedDeadline; + void _applyDeadline(int deadlineEpochSeconds, int totalSeconds) { + if (_appliedDeadline == deadlineEpochSeconds) return; + _appliedDeadline = deadlineEpochSeconds; + final now = DateTime.now().millisecondsSinceEpoch ~/ 1000; + final diff = deadlineEpochSeconds - now; + if (!mounted) return; + setState(() { + _totalCountdownSeconds = + totalSeconds > 0 ? totalSeconds : _kCountdownSeconds; + _remaining = diff > 0 ? Duration(seconds: diff) : Duration.zero; + }); + } + + /// Clears the countdown when the current state no longer has one (e.g. a + /// waiting order advanced to active / fiat-sent). Idempotent: does nothing if + /// no deadline is currently applied, so it won't setState on every rebuild. + void _clearDeadline() { + if (_appliedDeadline == null && _remaining == Duration.zero) return; + _appliedDeadline = null; + if (!mounted) return; + setState(() { + _remaining = Duration.zero; + }); } void _startCountdown() { @@ -542,12 +554,42 @@ class _TradeDetailScreenState extends ConsumerState { final allOrders = ref.watch(orderBookProvider).valueOrNull ?? []; final order = allOrders.where((o) => o.id == widget.orderId).firstOrNull; + // #270: drive the waiting-state countdown from the node's real + // expiration_seconds and the trade's timeout_at, not the 24 h pending + // expiry. _applyDeadline only resets the ticking timer when the target + // changes, so unrelated rebuilds don't disturb the per-second tick. UI + // only informs — the daemon stays the authority on expiry. + final tradeInfo = ref.watch(tradeInfoProvider(widget.orderId)).valueOrNull; // Counterpart (taker) reputation snapshot persisted from the daemon's // follow-up Peer DM (#305). Read via tradeInfoProvider (not the polling // stream): it refreshes on the TradeUpdate the Rust side emits after // persisting the snapshot. Present only once someone took the order, and // the taker's role is the opposite of the user's own. - final trade = ref.watch(tradeInfoProvider(widget.orderId)).valueOrNull; + final trade = tradeInfo; + final expirationSeconds = + ref.watch(mostroNodeProvider).valueOrNull?.expirationSeconds; + final countdown = waitingCountdownDeadline( + status: tradeStatusAsync.valueOrNull, + pendingExpiresAt: order?.expiresAt, + pendingCreatedAtEpoch: + order != null ? order.createdAt.millisecondsSinceEpoch ~/ 1000 : null, + timeoutAtEpoch: tradeInfo?.timeoutAt != null + ? platformInt64ToInt(tradeInfo!.timeoutAt!) + : null, + expirationSeconds: expirationSeconds, + ); + // When the resolved state has no countdown (active / fiat-sent / terminal), + // clear any timer left over from a prior waiting state so a stale countdown + // never lingers across the transition. + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted) return; + if (countdown != null) { + _applyDeadline( + countdown.deadlineEpochSeconds, countdown.totalWindowSeconds); + } else { + _clearDeadline(); + } + }); final inFlight = const { TradeStatus.waitingInvoice, diff --git a/test/features/order/utils/waiting_countdown_test.dart b/test/features/order/utils/waiting_countdown_test.dart new file mode 100644 index 00000000..a483661e --- /dev/null +++ b/test/features/order/utils/waiting_countdown_test.dart @@ -0,0 +1,117 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:mostro/features/order/utils/waiting_countdown.dart'; +import 'package:mostro/src/rust/api/types.dart'; + +void main() { + group('waitingCountdownDeadline', () { + test('pending counts to the pending expiry; ring spans creation to expiry', + () { + // 24 h window: created at epoch 1000, expires at 1000 + 86400. + const createdAt = 1000; + final expiresAt = + DateTime.fromMillisecondsSinceEpoch((createdAt + 86400) * 1000); + final r = waitingCountdownDeadline( + status: OrderStatus.pending, + pendingExpiresAt: expiresAt, + pendingCreatedAtEpoch: createdAt, + expirationSeconds: 900, + ); + expect(r, isNotNull); + expect(r!.deadlineEpochSeconds, createdAt + 86400); + // The ring spans the whole pending window, not the waiting window (#306). + expect(r.totalWindowSeconds, 86400); + }); + + test('pending ring falls back to the window when no createdAt is given', () { + final r = waitingCountdownDeadline( + status: OrderStatus.pending, + pendingExpiresAt: DateTime.fromMillisecondsSinceEpoch(2000 * 1000), + pendingCreatedAtEpoch: null, + expirationSeconds: 900, + ); + expect(r, isNotNull); + expect(r!.deadlineEpochSeconds, 2000); + expect(r.totalWindowSeconds, 900); + }); + + test('pending with no expiry yields no countdown', () { + final r = waitingCountdownDeadline( + status: OrderStatus.pending, + pendingExpiresAt: null, + expirationSeconds: 900, + ); + expect(r, isNull); + }); + + test('waiting state counts to the persisted timeout_at', () { + for (final status in [ + OrderStatus.waitingBuyerInvoice, + OrderStatus.waitingPayment, + ]) { + final r = waitingCountdownDeadline( + status: status, + timeoutAtEpoch: 5000, + expirationSeconds: 900, + ); + expect(r, isNotNull, reason: '$status'); + expect(r!.deadlineEpochSeconds, 5000, reason: '$status'); + expect(r.totalWindowSeconds, 900, reason: '$status'); + } + }); + + test( + 'a waiting order with no timeout_at yields no countdown, not a past ' + 'deadline (#306: no startedAt fallback)', () { + // startedAt is order-creation time; for a maker whose order sat in the + // book before being taken, anchoring on it produced a deadline already in + // the past — a countdown born at zero. With no timeout_at the helper now + // returns null rather than a bogus deadline. + for (final status in [ + OrderStatus.waitingBuyerInvoice, + OrderStatus.waitingPayment, + ]) { + final r = waitingCountdownDeadline( + status: status, + timeoutAtEpoch: null, + expirationSeconds: 900, + ); + expect(r, isNull, reason: '$status'); + } + }); + + test('non-countdown states produce no countdown', () { + for (final status in [ + OrderStatus.active, + OrderStatus.fiatSent, + OrderStatus.success, + OrderStatus.canceled, + null, + ]) { + final r = waitingCountdownDeadline( + status: status, + pendingExpiresAt: DateTime.fromMillisecondsSinceEpoch(2000 * 1000), + timeoutAtEpoch: 5000, + expirationSeconds: 900, + ); + expect(r, isNull, reason: '$status'); + } + }); + + test('the resolved deadline is stable across repeated resolutions ' + '(#270 regression: no now-drift)', () { + // The helper is pure: given the same inputs it returns the same deadline, + // so the ticking per-second rebuild never slides the target forward. + CountdownDeadline? resolve() => waitingCountdownDeadline( + status: OrderStatus.waitingPayment, + timeoutAtEpoch: 5000, + expirationSeconds: 900, + ); + final first = resolve(); + final second = resolve(); + final third = resolve(); + expect(first!.deadlineEpochSeconds, 5000); + expect(second!.deadlineEpochSeconds, first.deadlineEpochSeconds); + expect(third!.deadlineEpochSeconds, first.deadlineEpochSeconds); + }); + }); +}