diff --git a/CLAUDE.md b/CLAUDE.md index f2e0a075..831889af 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -125,14 +125,14 @@ Automatic cleanup system that prevents sessions from becoming orphaned when Most - **Purpose**: Prevents orphan sessions when Mostro doesn't respond within 10 seconds - **Cleanup**: Deletes session, shows localized notification, navigates to order book - **Cancellation**: Timer automatically cancelled when any response received from Mostro -- **Implementation**: `AbstractMostroNotifier.startSessionTimeoutCleanup()` in `abstract_mostro_notifier.dart:286-305` +- **Implementation**: `AbstractMostroNotifier.startSessionTimeoutCleanup()` method in `abstract_mostro_notifier.dart` **Order Creation Protection**: - **Activation**: Started automatically when users create orders (`AddOrderNotifier.submitOrder`) - **Purpose**: Prevents orphan sessions when Mostro doesn't respond to new order creation within 10 seconds - **Cleanup**: Deletes temporary session, shows localized notification, navigates to order book - **Cancellation**: Timer automatically cancelled when any response received from Mostro -- **Implementation**: `AbstractMostroNotifier.startSessionTimeoutCleanupForRequestId()` in `abstract_mostro_notifier.dart` +- **Implementation**: `AbstractMostroNotifier.startSessionTimeoutCleanupForRequestId()` method in `abstract_mostro_notifier.dart` #### **Localized User Feedback** ``` @@ -245,6 +245,18 @@ When orders are canceled, Mostro sends `Action.canceled` gift wrap: - **Implementation**: Custom `timeAgoWithLocale()` method in NostrEvent extension - **Usage**: Automatically uses app's current locale for "hace X horas" vs "hours ago" +### Dynamic Countdown Timer System +- **DynamicCountdownWidget**: Intelligent countdown widget for pending orders with automatic day/hour scaling +- **Implementation**: Located in `lib/shared/widgets/dynamic_countdown_widget.dart` +- **Data Source**: Uses exact `order_expires_at` timestamps from Mostro protocol for precision +- **Dual Display Modes**: + - **Day Scale** (>24h remaining): Shows "14d 20h 06m" format with day-based circular progress + - **Hour Scale** (≤24h remaining): Shows "HH:MM:SS" format with hour-based circular progress +- **Automatic Transition**: Switches at exactly 24:00:00 remaining time +- **Localization**: Uses `S.of(context)!.timeLeftLabel()` for internationalized display +- **Scope**: Only for pending status orders; waiting orders use separate countdown system +- **Integration**: Shared across TakeOrderScreen and TradeDetailScreen for consistency + ## Relay Synchronization System ### Overview diff --git a/docs/architecture/TIMEOUT_DETECTION_AND_SESSION_CLEANUP.md b/docs/architecture/TIMEOUT_DETECTION_AND_SESSION_CLEANUP.md index aff433be..127ed433 100755 --- a/docs/architecture/TIMEOUT_DETECTION_AND_SESSION_CLEANUP.md +++ b/docs/architecture/TIMEOUT_DETECTION_AND_SESSION_CLEANUP.md @@ -306,12 +306,81 @@ final countdownTimeProvider = StreamProvider((ref) { - **Resource efficiency**: Single timer supports multiple subscribers - **Memory leak prevention**: Proper disposal handling -### Countdown UI Integration +### Dynamic Countdown Timer System + +The application now uses a unified `DynamicCountdownWidget` for all pending order countdown timers, providing intelligent scaling and precise timestamp calculations. + +#### **DynamicCountdownWidget Architecture** + +```dart +// lib/shared/widgets/dynamic_countdown_widget.dart +class DynamicCountdownWidget extends ConsumerWidget { + final DateTime expiration; + final DateTime createdAt; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final remainingTime = expiration.isAfter(now) ? expiration.difference(now) : Duration.zero; + final useDayScale = remainingTime.inHours > 24; + + if (useDayScale) { + // Day scale: "14d 20h 06m" format for >24 hours + final daysLeft = (remainingTime.inHours / 24).floor(); + final hoursLeftInDay = remainingTime.inHours % 24; + final minutesLeftInHour = remainingTime.inMinutes % 60; + return CircularCountdown(countdownTotal: totalDays, countdownRemaining: daysLeft); + } else { + // Hour scale: "HH:MM:SS" format for ≤24 hours + final hoursLeft = remainingTime.inHours.clamp(0, totalHours); + final minutesLeft = remainingTime.inMinutes % 60; + final secondsLeft = remainingTime.inSeconds % 60; + return CircularCountdown(countdownTotal: totalHours, countdownRemaining: hoursLeft); + } + } +} +``` + +#### **Key Features** + +1. **Automatic Scaling**: Switches between day/hour formats based on remaining time +2. **Exact Timestamps**: Uses `expires_at` tag for precise calculations +3. **Dynamic Display**: + - **>24 hours**: Day scale with "14d 20h 06m" format + - **≤24 hours**: Hour scale with "HH:MM:SS" format +4. **Intelligent Rounding**: Circle divisions use intelligent rounding (28.2h → 28h, 23.7h → 24h) +5. **Shared Component**: Eliminates 96 lines of duplicated countdown code + +#### **Integration Points** + +**TakeOrderScreen Usage**: +```dart +// lib/features/order/screens/take_order_screen.dart - _buildCountDownTime method +return DynamicCountdownWidget( + expiration: DateTime.fromMillisecondsSinceEpoch(expiresAtTimestamp * 1000), + createdAt: order.createdAt!, +); +``` + +**TradeDetailScreen Usage**: +```dart +// lib/features/trades/screens/trade_detail_screen.dart - trade details widget tree +_CountdownWidget( + orderId: orderId, + tradeState: tradeState, + expiresAtTimestamp: orderPayload.expiresAt != null ? orderPayload.expiresAt! * 1000 : null, +), +``` + +#### **Scope and Limitations** + +- **Pending Orders Only**: DynamicCountdownWidget is specifically designed for orders in `Status.pending` +- **Waiting Orders Use Different System**: Orders in `Status.waitingBuyerInvoice` and `Status.waitingPayment` use separate countdown logic based on `expirationSeconds` + message timestamps +- **Data Source**: Uses `expires_at` Nostr tag for exact expiration timestamps rather than calculated values #### **Real-time Countdown Widget** ```dart -// lib/features/trades/screens/trade_detail_screen.dart - Lines 862-1062 +// lib/features/trades/screens/trade_detail_screen.dart - _CountdownWidget class class _CountdownWidget extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { @@ -344,7 +413,7 @@ class _CountdownWidget extends ConsumerWidget { The countdown displays different behaviors based on order status: ```dart -// Lines 916-1023: Countdown logic by order status +// _buildCountDownTime method: Countdown logic by order status Widget? _buildCountDownTime(BuildContext context, WidgetRef ref, OrderState tradeState, List messages, int? expiresAtTimestamp) { @@ -406,7 +475,7 @@ Widget? _buildCountDownTime(BuildContext context, WidgetRef ref, #### **Message State Detection** ```dart -// Lines 1025-1050: Find the message that caused the current state +// _findMessageForState method: Find the message that caused the current state MostroMessage? _findMessageForState(List messages, Status status) { // Sort messages by timestamp (most recent first) final sortedMessages = List.from(messages) @@ -728,7 +797,7 @@ The system automatically starts cleanup timers for both order creation and order When users take orders, a cleanup timer is automatically started to prevent sessions from becoming orphaned if Mostro doesn't respond: ```dart -// lib/features/order/notfiers/abstract_mostro_notifier.dart:286-305 +// lib/features/order/notfiers/abstract_mostro_notifier.dart - startSessionTimeoutCleanup method static void startSessionTimeoutCleanup(String orderId, Ref ref) { // Cancel existing timer if any _sessionTimeouts[orderId]?.cancel(); diff --git a/lib/data/models/nostr_event.dart b/lib/data/models/nostr_event.dart index ffa8e57d..e39d5190 100644 --- a/lib/data/models/nostr_event.dart +++ b/lib/data/models/nostr_event.dart @@ -39,6 +39,7 @@ extension NostrEventExtensions on NostrEvent { String? timeAgoWithLocale(String? locale) => _timeAgo(_getTagValue('expiration'), locale); DateTime get expirationDate => _getTimeStamp(_getTagValue('expiration')!); + String? get expiresAt => _getTagValue('expires_at'); String? get platform => _getTagValue('y'); String get type => _getTagValue('z')!; diff --git a/lib/data/models/order.dart b/lib/data/models/order.dart index d0a75304..8e086a52 100644 --- a/lib/data/models/order.dart +++ b/lib/data/models/order.dart @@ -192,7 +192,9 @@ class Order implements Payload { paymentMethod: event.paymentMethods.join(','), premium: event.premium as int, createdAt: event.createdAt as int, - expiresAt: event.expiration as int?, + expiresAt: event.expiresAt != null + ? int.tryParse(event.expiresAt!) + : null, ); } diff --git a/lib/features/order/screens/take_order_screen.dart b/lib/features/order/screens/take_order_screen.dart index 8ae04279..065bd6ce 100644 --- a/lib/features/order/screens/take_order_screen.dart +++ b/lib/features/order/screens/take_order_screen.dart @@ -1,4 +1,3 @@ -import 'package:circular_countdown/circular_countdown.dart'; import 'package:dart_nostr/nostr/model/event/event.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; @@ -18,8 +17,8 @@ import 'package:mostro_mobile/shared/providers/exchange_service_provider.dart'; import 'package:mostro_mobile/shared/utils/currency_utils.dart'; import 'package:mostro_mobile/shared/widgets/custom_card.dart'; -import 'package:mostro_mobile/features/mostro/mostro_instance.dart'; import 'package:mostro_mobile/shared/providers/time_provider.dart'; +import 'package:mostro_mobile/shared/widgets/dynamic_countdown_widget.dart'; import 'package:mostro_mobile/generated/l10n.dart'; class TakeOrderScreen extends ConsumerStatefulWidget { @@ -88,7 +87,7 @@ class _TakeOrderScreenState extends ConsumerState { _buildCreatorReputation(order), const SizedBox(height: 24), _CountdownWidget( - expirationDate: order.expirationDate, + order: order, ), const SizedBox(height: 36), _buildActionButtons(context, ref, order), @@ -443,10 +442,10 @@ class _TakeOrderScreenState extends ConsumerState { /// Widget that displays a real-time countdown timer for pending orders class _CountdownWidget extends ConsumerWidget { - final DateTime expirationDate; + final NostrEvent order; const _CountdownWidget({ - required this.expirationDate, + required this.order, }); @override @@ -456,7 +455,7 @@ class _CountdownWidget extends ConsumerWidget { return timeAsync.when( data: (currentTime) { - return _buildCountDownTime(context, ref, expirationDate); + return _buildCountDownTime(context, ref, order); }, loading: () => const CircularProgressIndicator(), error: (error, stack) => const SizedBox.shrink(), @@ -464,47 +463,26 @@ class _CountdownWidget extends ConsumerWidget { } Widget _buildCountDownTime( - BuildContext context, WidgetRef ref, DateTime expiration) { - Duration countdown = Duration(hours: 0); - final now = DateTime.now(); - - // Handle edge case: expiration in the past - if (expiration.isBefore(now.subtract(const Duration(hours: 1)))) { - // If expiration is more than 1 hour in the past, likely invalid + BuildContext context, WidgetRef ref, NostrEvent order) { + // Use exact timestamps from expires_at + if (order.expiresAt == null) { + // No valid expiration timestamp available return const SizedBox.shrink(); } - if (expiration.isAfter(now)) { - countdown = expiration.difference(now); + final expiresAtSeconds = int.tryParse(order.expiresAt.toString()); + if (expiresAtSeconds == null || expiresAtSeconds <= 0) { + return const SizedBox.shrink(); } - - // Get dynamic expiration hours from Mostro instance - final mostroInstance = ref.read(orderRepositoryProvider).mostroInstance; - final maxOrderHours = - mostroInstance?.expirationHours ?? 24; // fallback to 24 hours - - // Validate expiration hours - if (maxOrderHours <= 0 || maxOrderHours > 168) { - // Max 1 week + final expiration = DateTime.fromMillisecondsSinceEpoch(expiresAtSeconds * 1000); + final createdAt = order.createdAt; + if (createdAt == null) { return const SizedBox.shrink(); } - final hoursLeft = countdown.inHours.clamp(0, maxOrderHours); - final minutesLeft = countdown.inMinutes % 60; - final secondsLeft = countdown.inSeconds % 60; - - final formattedTime = - '${hoursLeft.toString().padLeft(2, '0')}:${minutesLeft.toString().padLeft(2, '0')}:${secondsLeft.toString().padLeft(2, '0')}'; - - return Column( - children: [ - CircularCountdown( - countdownTotal: maxOrderHours, - countdownRemaining: hoursLeft, - ), - const SizedBox(height: 16), - Text(S.of(context)!.timeLeftLabel(formattedTime)), - ], + return DynamicCountdownWidget( + expiration: expiration, + createdAt: createdAt, ); } } diff --git a/lib/features/trades/screens/trade_detail_screen.dart b/lib/features/trades/screens/trade_detail_screen.dart index 668f1fb8..67394bd2 100644 --- a/lib/features/trades/screens/trade_detail_screen.dart +++ b/lib/features/trades/screens/trade_detail_screen.dart @@ -23,6 +23,7 @@ import 'package:mostro_mobile/features/mostro/mostro_instance.dart'; import 'package:mostro_mobile/shared/providers/mostro_storage_provider.dart'; import 'package:mostro_mobile/data/models/mostro_message.dart'; import 'package:mostro_mobile/shared/providers/time_provider.dart'; +import 'package:mostro_mobile/shared/widgets/dynamic_countdown_widget.dart'; import 'package:mostro_mobile/features/disputes/providers/dispute_providers.dart'; import 'package:mostro_mobile/generated/l10n.dart'; @@ -978,19 +979,14 @@ class _CountdownWidget extends ConsumerWidget { // Show countdown ONLY for these 3 specific statuses if (status == Status.pending) { - // Pending orders: use expirationHours - final expHours = - mostroInstance?.expirationHours ?? 24; // 24 hours fallback - final countdownDuration = Duration(hours: expHours); - - // Handle edge case: invalid timestamp - if (expiresAtTimestamp != null && expiresAtTimestamp <= 0) { - expiresAtTimestamp = null; + // Pending orders: use exact timestamps from expires_at + if (expiresAtTimestamp == null || expiresAtTimestamp <= 0) { + // No valid expiration timestamp available + return null; } - final expiration = expiresAtTimestamp != null - ? DateTime.fromMillisecondsSinceEpoch(expiresAtTimestamp) - : now.add(countdownDuration); + final expiration = DateTime.fromMillisecondsSinceEpoch(expiresAtTimestamp); + final createdAt = DateTime.fromMillisecondsSinceEpoch((tradeState.order?.createdAt ?? 0) * 1000); // Handle edge case: expiration in the past if (expiration.isBefore(now.subtract(const Duration(hours: 1)))) { @@ -998,26 +994,9 @@ class _CountdownWidget extends ConsumerWidget { return null; } - final Duration difference = expiration.isAfter(now) - ? expiration.difference(now) - : const Duration(); - - final hoursLeft = difference.inHours.clamp(0, expHours); - final minutesLeft = difference.inMinutes % 60; - final secondsLeft = difference.inSeconds % 60; - - final formattedTime = - '${hoursLeft.toString().padLeft(2, '0')}:${minutesLeft.toString().padLeft(2, '0')}:${secondsLeft.toString().padLeft(2, '0')}'; - - return Column( - children: [ - CircularCountdown( - countdownTotal: expHours, - countdownRemaining: hoursLeft, - ), - const SizedBox(height: 16), - Text(S.of(context)!.timeLeftLabel(formattedTime)), - ], + return DynamicCountdownWidget( + expiration: expiration, + createdAt: createdAt, ); } else if (status == Status.waitingBuyerInvoice || status == Status.waitingPayment) { diff --git a/lib/shared/widgets/dynamic_countdown_widget.dart b/lib/shared/widgets/dynamic_countdown_widget.dart new file mode 100644 index 00000000..4d762af6 --- /dev/null +++ b/lib/shared/widgets/dynamic_countdown_widget.dart @@ -0,0 +1,93 @@ +import 'package:circular_countdown/circular_countdown.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:mostro_mobile/generated/l10n.dart'; + +/// Shared countdown widget for orders in PENDING status only +/// +/// Displays a dynamic circular countdown timer that automatically scales between day/hour modes: +/// - >24 hours remaining: Day scale showing "14d 20h 06m" format +/// - ≤24 hours remaining: Hour scale showing "HH:MM:SS" format +/// +/// Uses exact timestamps from expires_at tag for precise calculations. +/// +/// Note: Orders in waiting status (waitingBuyerInvoice, waitingPayment) use +/// a different countdown system based on expirationSeconds + message timestamps. +class DynamicCountdownWidget extends ConsumerWidget { + final DateTime expiration; + final DateTime createdAt; + + const DynamicCountdownWidget({ + super.key, + required this.expiration, + required this.createdAt, + }); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final now = DateTime.now(); + + // Handle edge case: expiration in the past + if (expiration.isBefore(now.subtract(const Duration(hours: 1)))) { + // If expiration is more than 1 hour in the past, likely invalid + return const SizedBox.shrink(); + } + + // Calculate total duration and remaining time + final totalDuration = expiration.difference(createdAt); + + // Early return if expiration is at or before creation time + if (expiration.isAtSameMomentAs(createdAt) || expiration.isBefore(createdAt)) { + return const SizedBox.shrink(); + } + + final Duration remainingTime = expiration.isAfter(now) + ? expiration.difference(now) + : const Duration(); + + // Determine if we should use day scale (>24 hours remaining) or hour scale (≤24 hours) + final remainingHours = remainingTime.inHours; + final useDayScale = remainingHours > 24; + + if (useDayScale) { + // DAY SCALE: Show days and hours + final totalDays = ((totalDuration.inSeconds + 86399) ~/ 86400).clamp(1, double.infinity).toInt(); + final daysLeft = ((remainingTime.inHours / 24).floor()).clamp(0, totalDays); + final hoursLeftInDay = remainingTime.inHours % 24; + + final minutesLeftInHour = remainingTime.inMinutes % 60; + final formattedTime = '${daysLeft}d ${hoursLeftInDay}h ${minutesLeftInHour.toString().padLeft(2, '0')}m'; + + return Column( + children: [ + CircularCountdown( + countdownTotal: totalDays, + countdownRemaining: daysLeft, + ), + const SizedBox(height: 16), + Text(S.of(context)!.timeLeftLabel(formattedTime)), + ], + ); + } else { + // HOUR SCALE: Show hours, minutes, seconds (≤24 hours remaining) + final totalHours = ((totalDuration.inSeconds + 3599) ~/ 3600).clamp(1, double.infinity).toInt(); + final hoursLeft = remainingTime.inHours.clamp(0, totalHours); + final minutesLeft = remainingTime.inMinutes % 60; + final secondsLeft = remainingTime.inSeconds % 60; + + final formattedTime = + '${hoursLeft.toString().padLeft(2, '0')}:${minutesLeft.toString().padLeft(2, '0')}:${secondsLeft.toString().padLeft(2, '0')}'; + + return Column( + children: [ + CircularCountdown( + countdownTotal: totalHours, + countdownRemaining: hoursLeft, + ), + const SizedBox(height: 16), + Text(S.of(context)!.timeLeftLabel(formattedTime)), + ], + ); + } + } +} \ No newline at end of file