diff --git a/lib/features/chat/widgets/trade_state_header.dart b/lib/features/chat/widgets/trade_state_header.dart index e9021ab2..0482fea9 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, + timeoutAtEpoch: tradeInfo?.timeoutAt != null + ? platformInt64ToInt(tradeInfo!.timeoutAt!) + : null, + waitingSinceEpoch: + tradeInfo != null ? platformInt64ToInt(tradeInfo.startedAt) : 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..2a6753c8 --- /dev/null +++ b/lib/features/order/utils/waiting_countdown.dart @@ -0,0 +1,59 @@ +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 state-change +/// deadline — the trade's [timeoutAtEpoch] when the daemon persisted one, +/// else [waitingSinceEpoch] + `expiration_seconds` (the state-change message +/// timestamp plus the window, per the v1 timeout contract), falling back to +/// [kWaitingCountdownFallbackSeconds] for the window when the node omits it. +/// - **Any other state**: no countdown (null). +/// +/// The fallback is anchored on [waitingSinceEpoch] (a fixed timestamp such as +/// `TradeInfo.startedAt`) rather than the current time, so the deadline is +/// stable across the per-second rebuilds that drive the ticking UI — otherwise +/// a `now + window` deadline would slide forward every second and never reach +/// zero. This keeps the helper pure (no clock read, no cache). +/// +/// 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? timeoutAtEpoch, + int? waitingSinceEpoch, + int? expirationSeconds, +}) { + final window = expirationSeconds ?? kWaitingCountdownFallbackSeconds; + switch (status) { + case OrderStatus.pending: + if (pendingExpiresAt == null) return null; + final deadline = pendingExpiresAt.millisecondsSinceEpoch ~/ 1000; + return (deadlineEpochSeconds: deadline, totalWindowSeconds: window); + case OrderStatus.waitingBuyerInvoice: + case OrderStatus.waitingPayment: + if (timeoutAtEpoch != null) { + return (deadlineEpochSeconds: timeoutAtEpoch, totalWindowSeconds: window); + } + if (waitingSinceEpoch != null) { + return ( + deadlineEpochSeconds: waitingSinceEpoch + window, + 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 ecadeb50..4c4aa832 100644 --- a/lib/features/trades/screens/trade_detail_screen.dart +++ b/lib/features/trades/screens/trade_detail_screen.dart @@ -16,6 +16,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/trades/providers/trades_providers.dart'; import 'package:mostro/features/trades/widgets/dispute_confirmation_dialog.dart'; import 'package:mostro/features/trades/widgets/release_confirmation_dialog.dart'; @@ -38,7 +40,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. @@ -102,7 +106,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(); } @@ -112,26 +117,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() { @@ -497,6 +509,37 @@ 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; + final expirationSeconds = + ref.watch(mostroNodeProvider).valueOrNull?.expirationSeconds; + final countdown = waitingCountdownDeadline( + status: tradeStatusAsync.valueOrNull, + pendingExpiresAt: order?.expiresAt, + timeoutAtEpoch: tradeInfo?.timeoutAt != null + ? platformInt64ToInt(tradeInfo!.timeoutAt!) + : null, + waitingSinceEpoch: + tradeInfo != null ? platformInt64ToInt(tradeInfo.startedAt) : 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, TradeStatus.waitingPayment, 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..450fa8da --- /dev/null +++ b/test/features/order/utils/waiting_countdown_test.dart @@ -0,0 +1,118 @@ +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, window from expiration_seconds', + () { + final expiresAt = DateTime.fromMillisecondsSinceEpoch(2000 * 1000); + final r = waitingCountdownDeadline( + status: OrderStatus.pending, + pendingExpiresAt: expiresAt, + 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 prefers the persisted timeout_at', () { + for (final status in [ + OrderStatus.waitingBuyerInvoice, + OrderStatus.waitingPayment, + ]) { + final r = waitingCountdownDeadline( + status: status, + timeoutAtEpoch: 5000, + waitingSinceEpoch: 1000, + expirationSeconds: 900, + ); + expect(r, isNotNull, reason: '$status'); + expect(r!.deadlineEpochSeconds, 5000, reason: '$status'); + expect(r.totalWindowSeconds, 900, reason: '$status'); + } + }); + + test('waiting falls back to startedAt + window when timeout_at is absent', + () { + final r = waitingCountdownDeadline( + status: OrderStatus.waitingPayment, + timeoutAtEpoch: null, + waitingSinceEpoch: 1000, + expirationSeconds: 900, + ); + expect(r, isNotNull); + expect(r!.deadlineEpochSeconds, 1900); // 1000 + 900, not now-based + expect(r.totalWindowSeconds, 900); + }); + + test('waiting fallback uses the 900 s default when the node omits ' + 'expiration_seconds', () { + final r = waitingCountdownDeadline( + status: OrderStatus.waitingPayment, + timeoutAtEpoch: null, + waitingSinceEpoch: 1000, + expirationSeconds: null, + ); + expect(r, isNotNull); + expect(r!.totalWindowSeconds, kWaitingCountdownFallbackSeconds); + expect(r.deadlineEpochSeconds, 1000 + kWaitingCountdownFallbackSeconds); + }); + + test('waiting with neither timeout_at nor a start anchor yields no ' + 'countdown', () { + final r = waitingCountdownDeadline( + status: OrderStatus.waitingBuyerInvoice, + timeoutAtEpoch: null, + waitingSinceEpoch: null, + expirationSeconds: 900, + ); + expect(r, isNull); + }); + + 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, + waitingSinceEpoch: 1000, + expirationSeconds: 900, + ); + expect(r, isNull, reason: '$status'); + } + }); + + test('the fallback deadline is stable across repeated resolutions ' + '(#270 regression: no now-drift)', () { + CountdownDeadline? resolve() => waitingCountdownDeadline( + status: OrderStatus.waitingPayment, + timeoutAtEpoch: null, + waitingSinceEpoch: 1000, + expirationSeconds: 900, + ); + final first = resolve(); + final second = resolve(); + final third = resolve(); + expect(first!.deadlineEpochSeconds, 1900); + expect(second!.deadlineEpochSeconds, first.deadlineEpochSeconds); + expect(third!.deadlineEpochSeconds, first.deadlineEpochSeconds); + }); + }); +}