Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 14 additions & 2 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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**
```
Expand Down Expand Up @@ -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
Expand Down
79 changes: 74 additions & 5 deletions docs/architecture/TIMEOUT_DETECTION_AND_SESSION_CLEANUP.md
Original file line number Diff line number Diff line change
Expand Up @@ -306,12 +306,81 @@ final countdownTimeProvider = StreamProvider<DateTime>((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) {
Expand Down Expand Up @@ -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<MostroMessage> messages, int? expiresAtTimestamp) {

Expand Down Expand Up @@ -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<MostroMessage> messages, Status status) {
// Sort messages by timestamp (most recent first)
final sortedMessages = List<MostroMessage>.from(messages)
Expand Down Expand Up @@ -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();
Expand Down
1 change: 1 addition & 0 deletions lib/data/models/nostr_event.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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')!;

Expand Down
4 changes: 3 additions & 1 deletion lib/data/models/order.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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,
);
}

Expand Down
58 changes: 18 additions & 40 deletions lib/features/order/screens/take_order_screen.dart
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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 {
Expand Down Expand Up @@ -88,7 +87,7 @@ class _TakeOrderScreenState extends ConsumerState<TakeOrderScreen> {
_buildCreatorReputation(order),
const SizedBox(height: 24),
_CountdownWidget(
expirationDate: order.expirationDate,
order: order,
),
const SizedBox(height: 36),
_buildActionButtons(context, ref, order),
Expand Down Expand Up @@ -443,10 +442,10 @@ class _TakeOrderScreenState extends ConsumerState<TakeOrderScreen> {

/// 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
Expand All @@ -456,55 +455,34 @@ 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(),
);
}

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,
);
}
}
41 changes: 10 additions & 31 deletions lib/features/trades/screens/trade_detail_screen.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -978,46 +979,24 @@ 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)))) {
// If expiration is more than 1 hour in the past, likely invalid
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) {
Expand Down
Loading