diff --git a/lib/core/app_routes.dart b/lib/core/app_routes.dart index c13d437a..d9511d02 100644 --- a/lib/core/app_routes.dart +++ b/lib/core/app_routes.dart @@ -20,7 +20,11 @@ import 'package:mostro_mobile/features/order/screens/pay_lightning_invoice_scree import 'package:mostro_mobile/features/order/screens/take_order_screen.dart'; import 'package:mostro_mobile/features/auth/screens/register_screen.dart'; import 'package:mostro_mobile/features/walkthrough/screens/walkthrough_screen.dart'; + +import 'package:mostro_mobile/features/disputes/screens/dispute_chat_screen.dart'; + import 'package:mostro_mobile/features/notifications/screens/notifications_screen.dart'; + import 'package:mostro_mobile/features/walkthrough/providers/first_run_provider.dart'; import 'package:mostro_mobile/shared/widgets/navigation_listener_widget.dart'; import 'package:mostro_mobile/shared/widgets/notification_listener_widget.dart'; @@ -143,6 +147,17 @@ GoRouter createRouter(WidgetRef ref) { orderId: state.pathParameters['orderId']!, )), ), + GoRoute( + path: '/dispute_details/:disputeId', + pageBuilder: (context, state) { + final disputeId = state.pathParameters['disputeId']!; + return buildPageWithDefaultTransition( + context: context, + state: state, + child: DisputeChatScreen(disputeId: disputeId), + ); + }, + ), GoRoute( path: '/register', pageBuilder: (context, state) => diff --git a/lib/data/models/dispute.dart b/lib/data/models/dispute.dart index 22315731..1e1c9b2a 100644 --- a/lib/data/models/dispute.dart +++ b/lib/data/models/dispute.dart @@ -1,36 +1,207 @@ import 'package:mostro_mobile/data/models/payload.dart'; +import 'package:mostro_mobile/data/models/order.dart'; +/// Enum representing semantic keys for dispute descriptions +/// These keys will be used for localization in the UI +enum DisputeDescriptionKey { + initiatedByUser, // You opened this dispute + initiatedByPeer, // A dispute was opened against you + inProgress, // Dispute is being reviewed by an admin + resolved, // Dispute has been resolved + sellerRefunded, // Dispute resolved - seller refunded + unknown // Unknown status +} + +/// Enum representing the user's role in a dispute +enum UserRole { + buyer, + seller, + unknown +} + +/// Semantic keys for missing or unknown values +class DisputeSemanticKeys { + static const String unknownOrderId = 'unknownOrderId'; + static const String unknownCounterparty = 'unknownCounterparty'; +} + +/// Represents a dispute in the Mostro system. +/// +/// A dispute can be initiated by either buyer or seller when there's a problem +/// with an order. The dispute is identified by a unique ID and is associated +/// with a specific order. class Dispute implements Payload { + static const _sentinel = Object(); final String disputeId; + final String? orderId; + final String? status; + final Order? order; + final String? disputeToken; + final String? adminPubkey; + final DateTime? adminTookAt; + final DateTime? createdAt; + final String? action; - Dispute({required this.disputeId}) { + Dispute({ + required this.disputeId, + this.orderId, + this.status, + this.order, + this.disputeToken, + this.adminPubkey, + this.adminTookAt, + this.createdAt, + this.action, + }) { if (disputeId.isEmpty) { throw ArgumentError('Dispute ID cannot be empty'); } } + /// Check if an admin has been assigned to this dispute + bool get hasAdmin => adminPubkey != null && adminPubkey!.isNotEmpty; + @override Map toJson() { - return { - type: disputeId, + final Map json = { + 'dispute': disputeId, }; + + if (orderId != null) { + json['order_id'] = orderId; + } + + if (status != null) { + json['status'] = status; + } + + if (order != null) { + json['order'] = order!.toJson(); + } + + if (disputeToken != null) { + json['dispute_token'] = disputeToken; + } + + if (adminPubkey != null) { + json['admin_pubkey'] = adminPubkey; + } + + if (adminTookAt != null) { + json['admin_took_at'] = adminTookAt!.millisecondsSinceEpoch; + } + + if (createdAt != null) { + json['created_at'] = createdAt!.millisecondsSinceEpoch; + } + + if (action != null) { + json['action'] = action; + } + + return json; } - factory Dispute.fromJson(Map json) { + /// Extract tag value from a Nostr event + /// Returns the value of the tag with the given name, or null if not found + static String? _extractTag(dynamic event, String name) { + try { + final tags = event.tags ?? const >[]; + final tag = tags.firstWhere( + (tag) => tag.isNotEmpty && tag[0] == name, + orElse: () => [], + ); + + if (tag.length > 1 && tag[1] != null && tag[1].toString().isNotEmpty) { + return tag[1].toString(); + } + return null; + } catch (e) { + return null; + } + } + + /// Extract created_at timestamp from a Nostr event and convert to milliseconds + /// Handles both seconds and milliseconds formats + static int _extractCreatedAtMillis(dynamic event) { + try { + final createdAtRaw = event.createdAt; + + if (createdAtRaw == null) { + return DateTime.now().millisecondsSinceEpoch; + } else if (createdAtRaw is int) { + // Nostr timestamps are in seconds, convert to milliseconds if needed + return createdAtRaw < 10000000000 + ? createdAtRaw * 1000 // Convert seconds to milliseconds + : createdAtRaw; // Already in milliseconds + } else if (createdAtRaw is DateTime) { + return createdAtRaw.millisecondsSinceEpoch; + } + return DateTime.now().millisecondsSinceEpoch; + } catch (e) { + return DateTime.now().millisecondsSinceEpoch; + } + } + + /// Create Dispute from NostrEvent and parsed content + factory Dispute.fromNostrEvent(dynamic event, Map content) { try { + // Extract dispute ID from 'd' tag, fallback to content or event ID + final disputeId = _extractTag(event, 'd') ?? + content['dispute_id'] ?? + content['dispute'] ?? + event.id ?? + ''; + + // Extract order ID from content + final orderId = content['order_id'] as String?; + // Extract status from 's' tag or content, default to 'initiated' + final status = _extractTag(event, 's') ?? + content['status'] as String? ?? + 'initiated'; + + // Extract creation timestamp from event + final createdAt = DateTime.fromMillisecondsSinceEpoch( + _extractCreatedAtMillis(event) + ); + + return Dispute( + disputeId: disputeId, + orderId: orderId, + status: status, + adminPubkey: content['admin_pubkey'] as String?, + createdAt: createdAt, + action: content['action'] as String?, + ); + } catch (e) { + throw FormatException('Failed to parse Dispute from NostrEvent: $e'); + } + } + + factory Dispute.fromJson(Map json) { + try { + // Extract dispute ID final oid = json['dispute']; if (oid == null) { throw FormatException('Missing required field: dispute'); } String disputeIdValue; + String? disputeTokenValue; + if (oid is List) { if (oid.isEmpty) { throw FormatException('Dispute list cannot be empty'); } disputeIdValue = oid[0]?.toString() ?? (throw FormatException('First element of dispute list is null')); + + // Extract token from array: [disputeId, userToken, peerToken] + // Index 1 is the user's token (who initiated the dispute) + if (oid.length > 1 && oid[1] != null) { + disputeTokenValue = oid[1].toString(); + } } else { disputeIdValue = oid.toString(); } @@ -39,24 +210,284 @@ class Dispute implements Payload { throw FormatException('Dispute ID cannot be empty'); } - return Dispute(disputeId: disputeIdValue); + // Extract optional fields + final orderId = json['order_id'] as String?; + final status = json['status'] as String?; + // Use token from array if available, otherwise fallback to json field + final disputeToken = disputeTokenValue ?? json['dispute_token'] as String?; + final adminPubkey = json['admin_pubkey'] as String?; + + // Extract admin_took_at timestamp + DateTime? adminTookAt; + if (json.containsKey('admin_took_at') && json['admin_took_at'] != null) { + final timestamp = json['admin_took_at']; + if (timestamp is int || timestamp is double) { + final timestampInt = timestamp is double ? timestamp.toInt() : timestamp as int; + final normalizedTimestamp = timestampInt < 10000000000 + ? timestampInt * 1000 // Convert seconds to milliseconds + : timestampInt; // Already in milliseconds + adminTookAt = DateTime.fromMillisecondsSinceEpoch(normalizedTimestamp); + } else if (timestamp is String) { + final parsed = DateTime.tryParse(timestamp); + if (parsed != null) { + adminTookAt = parsed; + } + } + } + + // Extract order if present + Order? order; + if (json.containsKey('order') && json['order'] != null) { + order = Order.fromJson(json['order'] as Map); + } + + // Extract created_at timestamp + DateTime? createdAt; + if (json.containsKey('created_at') && json['created_at'] != null) { + final timestamp = json['created_at']; + if (timestamp is int || timestamp is double) { + final timestampInt = timestamp is double ? timestamp.toInt() : timestamp as int; + final normalizedTimestamp = timestampInt < 10000000000 + ? timestampInt * 1000 // Convert seconds to milliseconds + : timestampInt; // Already in milliseconds + createdAt = DateTime.fromMillisecondsSinceEpoch(normalizedTimestamp); + } else if (timestamp is String) { + final parsed = DateTime.tryParse(timestamp); + if (parsed != null) { + createdAt = parsed; + } + } + } + + return Dispute( + disputeId: disputeIdValue, + orderId: orderId, + status: status, + order: order, + disputeToken: disputeToken, + adminPubkey: adminPubkey, + adminTookAt: adminTookAt, + createdAt: createdAt, + action: json['action'] as String?, + ); } catch (e) { throw FormatException('Failed to parse Dispute from JSON: $e'); } } + /// Creates a copy of this Dispute with the given fields replaced with the new values. + /// Pass explicit null to clear nullable fields. + Dispute copyWith({ + Object? disputeId = _sentinel, + Object? orderId = _sentinel, + Object? status = _sentinel, + Object? order = _sentinel, + Object? disputeToken = _sentinel, + Object? adminPubkey = _sentinel, + Object? adminTookAt = _sentinel, + Object? createdAt = _sentinel, + Object? action = _sentinel, + }) { + return Dispute( + disputeId: disputeId == _sentinel ? this.disputeId : disputeId as String, + orderId: orderId == _sentinel ? this.orderId : orderId as String?, + status: status == _sentinel ? this.status : status as String?, + order: order == _sentinel ? this.order : order as Order?, + disputeToken: disputeToken == _sentinel ? this.disputeToken : disputeToken as String?, + adminPubkey: adminPubkey == _sentinel ? this.adminPubkey : adminPubkey as String?, + adminTookAt: adminTookAt == _sentinel ? this.adminTookAt : adminTookAt as DateTime?, + createdAt: createdAt == _sentinel ? this.createdAt : createdAt as DateTime?, + action: action == _sentinel ? this.action : action as String?, + ); + } + @override String get type => 'dispute'; @override bool operator ==(Object other) { if (identical(this, other)) return true; - return other is Dispute && other.disputeId == disputeId; + return other is Dispute && + other.disputeId == disputeId && + other.orderId == orderId && + other.status == status && + other.order == order && + other.disputeToken == disputeToken && + other.adminPubkey == adminPubkey && + other.adminTookAt == adminTookAt && + other.createdAt == createdAt && + other.action == action; } @override - int get hashCode => disputeId.hashCode; + int get hashCode => Object.hash(disputeId, orderId, status, order, disputeToken, adminPubkey, adminTookAt, createdAt, action); @override - String toString() => 'Dispute(disputeId: $disputeId)'; + String toString() => 'Dispute(disputeId: $disputeId, orderId: $orderId, status: $status, disputeToken: $disputeToken, adminPubkey: $adminPubkey, adminTookAt: $adminTookAt, createdAt: $createdAt, action: $action)'; +} + +/// UI-facing view model for disputes used across widgets. +class DisputeData { + final String disputeId; + final String? orderId; + final String status; + final DisputeDescriptionKey descriptionKey; + final String? counterparty; + final bool? isCreator; + final DateTime createdAt; + final UserRole userRole; + + DisputeData({ + required this.disputeId, + this.orderId, + required this.status, + required this.descriptionKey, + this.counterparty, + this.isCreator, + required this.createdAt, + required this.userRole, + }); + + /// Create DisputeData from Dispute object with OrderState context + factory DisputeData.fromDispute(Dispute dispute, {dynamic orderState}) { + // Determine if user is the creator based on the OrderState action if available + bool? isUserCreator; + + if (orderState != null && orderState.action != null) { + // Use OrderState action which has the correct dispute initiation info + final actionString = orderState.action.toString(); + isUserCreator = actionString == 'dispute-initiated-by-you'; + } else if (dispute.action != null) { + // Fallback to dispute action - this should now be set correctly + isUserCreator = dispute.action == 'dispute-initiated-by-you'; + } else { + // If no action info is available, leave as null (unknown state) + // This removes the assumption that user is creator by default + isUserCreator = null; + } + + // Try to get counterparty info from order state and determine correct role + String? counterpartyName; + UserRole userRole = UserRole.unknown; + + if (orderState != null) { + // Get the counterparty nym using the same approach as chat + if (orderState.peer != null) { + counterpartyName = orderState.peer!.publicKey; // This will be resolved by nickNameProvider in the UI + } + } else if (dispute.adminPubkey != null && dispute.status != 'resolved') { + // Only use admin pubkey as counterparty if dispute is not resolved and no peer info + // For resolved disputes, we don't want to show admin as counterparty + counterpartyName = dispute.adminPubkey; + } + + // Determine if user is buyer or seller based on order type + if (orderState != null && orderState.order != null) { + // If order type is 'buy', then the order creator is buying (user is buyer) + // If order type is 'sell', then the order creator is selling (user is seller) + // The peer is always the opposite role + userRole = orderState.order!.kind.value == 'buy' ? UserRole.buyer : UserRole.seller; + } + + // Get the appropriate description key based on status and creator + final descriptionKey = _getDescriptionKeyForStatus(dispute.status ?? 'unknown', isUserCreator); + + return DisputeData( + disputeId: dispute.disputeId, + orderId: dispute.orderId, // No fallback to hardcoded string + status: dispute.status ?? 'unknown', + descriptionKey: descriptionKey, + counterparty: counterpartyName, + isCreator: isUserCreator, + createdAt: dispute.createdAt ?? DateTime.now(), + userRole: userRole, + ); + } + + /// Create DisputeData from DisputeEvent (legacy method) + factory DisputeData.fromDisputeEvent(dynamic disputeEvent, {String? userAction}) { + // Determine if user is the creator based on the action or other indicators + bool? isUserCreator = _determineIfUserIsCreator(disputeEvent, userAction); + + // Get the appropriate description key based on status and creator + final descriptionKey = _getDescriptionKeyForStatus(disputeEvent.status, isUserCreator); + + return DisputeData( + disputeId: disputeEvent.disputeId, + orderId: disputeEvent.orderId, // No fallback to hardcoded string + status: disputeEvent.status, + descriptionKey: descriptionKey, + counterparty: null, // Would need to fetch from order data + isCreator: isUserCreator, + createdAt: DateTime.fromMillisecondsSinceEpoch( + disputeEvent.createdAt is int + ? (disputeEvent.createdAt <= 9999999999 + ? disputeEvent.createdAt * 1000 // Convert seconds to milliseconds + : disputeEvent.createdAt) // Already in milliseconds + : DateTime.now().millisecondsSinceEpoch + ), + userRole: UserRole.unknown, // Default value for legacy method + ); + } + + /// Determine if the user is the creator of the dispute + static bool? _determineIfUserIsCreator(dynamic disputeEvent, String? userAction) { + // If we have userAction information, use it to determine creator + if (userAction != null) { + return userAction == 'dispute-initiated-by-you'; + } + + // Otherwise, return null for unknown state instead of assuming + // This removes the fallback assumption that user is creator + return null; + } + + /// Get a description key for the dispute status + static DisputeDescriptionKey _getDescriptionKeyForStatus(String status, bool? isUserCreator) { + switch (status.toLowerCase()) { + case 'initiated': + if (isUserCreator == null) { + return DisputeDescriptionKey.unknown; // Unknown creator state + } + return isUserCreator + ? DisputeDescriptionKey.initiatedByUser + : DisputeDescriptionKey.initiatedByPeer; + case 'in-progress': + return DisputeDescriptionKey.inProgress; + case 'resolved': + case 'solved': + return DisputeDescriptionKey.resolved; + case 'seller-refunded': + return DisputeDescriptionKey.sellerRefunded; + default: + return DisputeDescriptionKey.unknown; + } + } + + /// Backward compatibility getter for description + String get description { + switch (descriptionKey) { + case DisputeDescriptionKey.initiatedByUser: + return 'You opened this dispute'; + case DisputeDescriptionKey.initiatedByPeer: + return 'A dispute was opened against you'; + case DisputeDescriptionKey.inProgress: + return 'Dispute is being reviewed by an admin'; + case DisputeDescriptionKey.resolved: + return 'Dispute has been resolved'; + case DisputeDescriptionKey.sellerRefunded: + return 'Dispute resolved - seller refunded'; + case DisputeDescriptionKey.unknown: + return 'Unknown status'; + } + } + + /// Backward compatibility getter for userIsBuyer + bool get userIsBuyer => userRole == UserRole.buyer; + + /// Convenience getter for orderId with fallback + String get orderIdDisplay => orderId ?? DisputeSemanticKeys.unknownOrderId; + + /// Convenience getter for counterparty with fallback + String get counterpartyDisplay => counterparty ?? DisputeSemanticKeys.unknownCounterparty; } diff --git a/lib/data/models/dispute_chat.dart b/lib/data/models/dispute_chat.dart new file mode 100644 index 00000000..49400fd9 --- /dev/null +++ b/lib/data/models/dispute_chat.dart @@ -0,0 +1,50 @@ +/// Stub model for DisputeChat - UI only implementation +class DisputeChat { + final String id; + final String message; + final DateTime timestamp; + final bool isFromUser; + final String? adminPubkey; + + DisputeChat({ + required this.id, + required this.message, + required this.timestamp, + required this.isFromUser, + this.adminPubkey, + }); + + factory DisputeChat.fromJson(Map json) { + return DisputeChat( + id: json['id'] ?? '', + message: json['message'] ?? '', + timestamp: _parseTimestamp(json['timestamp']), + isFromUser: json['isFromUser'] ?? false, + adminPubkey: json['adminPubkey'], + ); + } + + Map toJson() { + return { + 'id': id, + 'message': message, + 'timestamp': timestamp.toIso8601String(), + 'isFromUser': isFromUser, + 'adminPubkey': adminPubkey, + }; + } + + static DateTime _parseTimestamp(dynamic v) { + if (v is int) { + // Treat values < 1e12 as seconds, convert to milliseconds + int milliseconds = v < 1e12 ? v * 1000 : v; + return DateTime.fromMillisecondsSinceEpoch(milliseconds); + } + if (v is String && v.isNotEmpty) { + DateTime? parsed = DateTime.tryParse(v); + if (parsed != null) return parsed; + } + // Final fallback + return DateTime.now(); + } +} \ No newline at end of file diff --git a/lib/data/models/dispute_event.dart b/lib/data/models/dispute_event.dart new file mode 100644 index 00000000..32a936e2 --- /dev/null +++ b/lib/data/models/dispute_event.dart @@ -0,0 +1,49 @@ +/// Stub model for DisputeEvent - UI only implementation +class DisputeEvent { + final String id; + final String disputeId; + final String orderId; + final String status; + final int createdAt; + + DisputeEvent({ + required this.id, + required this.disputeId, + required this.orderId, + required this.status, + required this.createdAt, + }); + + factory DisputeEvent.fromJson(Map json) { + return DisputeEvent( + id: json['id'] ?? '', + disputeId: json['disputeId'] ?? '', + orderId: json['orderId'] ?? '', + status: json['status'] ?? 'unknown', + createdAt: _parseCreatedAt(json['createdAt']), + ); + } + + Map toJson() { + return { + 'id': id, + 'disputeId': disputeId, + 'orderId': orderId, + 'status': status, + 'createdAt': createdAt, + }; + } + + static int _parseCreatedAt(dynamic v) { + if (v is int) { + // Treat values < 1_000_000_000_000 as seconds and multiply by 1000 + return v < 1000000000000 ? v * 1000 : v; + } + if (v is String) { + DateTime? parsed = DateTime.tryParse(v); + if (parsed != null) return parsed.millisecondsSinceEpoch; + } + // Default fallback + return DateTime.now().millisecondsSinceEpoch; + } +} \ No newline at end of file diff --git a/lib/data/repositories/dispute_repository.dart b/lib/data/repositories/dispute_repository.dart new file mode 100644 index 00000000..9c186b01 --- /dev/null +++ b/lib/data/repositories/dispute_repository.dart @@ -0,0 +1,49 @@ +import 'package:mostro_mobile/data/models/dispute.dart'; + +/// Stub repository for disputes - UI only implementation +class DisputeRepository { + static final DisputeRepository _instance = DisputeRepository._internal(); + factory DisputeRepository() => _instance; + DisputeRepository._internal(); + + Future> getUserDisputes() async { + // Mock implementation for UI testing + await Future.delayed(const Duration(milliseconds: 500)); + + return [ + Dispute( + disputeId: 'dispute_001', + orderId: 'order_001', + status: 'initiated', + createdAt: DateTime.now().subtract(const Duration(hours: 1)), + action: 'dispute-initiated-by-you', + ), + Dispute( + disputeId: 'dispute_002', + orderId: 'order_002', + status: 'in-progress', + createdAt: DateTime.now().subtract(const Duration(days: 1)), + action: 'dispute-initiated-by-peer', + adminPubkey: 'admin_123', + ), + ]; + } + + Future getDispute(String disputeId) async { + // Mock implementation + await Future.delayed(const Duration(milliseconds: 300)); + + return Dispute( + disputeId: disputeId, + orderId: 'order_${disputeId.substring(0, 8)}', + status: 'initiated', + createdAt: DateTime.now().subtract(const Duration(hours: 2)), + action: 'dispute-initiated-by-you', + ); + } + + Future sendDisputeMessage(String disputeId, String message) async { + // Mock implementation + await Future.delayed(const Duration(milliseconds: 200)); + } +} \ No newline at end of file diff --git a/lib/features/chat/providers/chat_tab_provider.dart b/lib/features/chat/providers/chat_tab_provider.dart new file mode 100644 index 00000000..0297dc70 --- /dev/null +++ b/lib/features/chat/providers/chat_tab_provider.dart @@ -0,0 +1,5 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +enum ChatTabType { messages, disputes } + +final chatTabProvider = StateProvider((ref) => ChatTabType.messages); diff --git a/lib/features/chat/screens/chat_rooms_list.dart b/lib/features/chat/screens/chat_rooms_list.dart index 94f09def..558c4e1a 100644 --- a/lib/features/chat/screens/chat_rooms_list.dart +++ b/lib/features/chat/screens/chat_rooms_list.dart @@ -2,44 +2,25 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:mostro_mobile/core/app_theme.dart'; -import 'package:mostro_mobile/data/models/chat_room.dart'; import 'package:mostro_mobile/features/chat/providers/chat_room_providers.dart'; import 'package:mostro_mobile/features/chat/widgets/chat_list_item.dart'; import 'package:mostro_mobile/features/chat/widgets/chat_tabs.dart'; import 'package:mostro_mobile/features/chat/widgets/empty_state_view.dart'; +import 'package:mostro_mobile/features/disputes/widgets/disputes_list.dart'; +import 'package:mostro_mobile/features/chat/providers/chat_tab_provider.dart'; import 'package:mostro_mobile/generated/l10n.dart'; import 'package:mostro_mobile/shared/widgets/bottom_nav_bar.dart'; import 'package:mostro_mobile/shared/widgets/custom_drawer_overlay.dart'; import 'package:mostro_mobile/shared/widgets/mostro_app_bar.dart'; -class ChatRoomsScreen extends ConsumerStatefulWidget { +class ChatRoomsScreen extends ConsumerWidget { const ChatRoomsScreen({super.key}); @override - ConsumerState createState() => _ChatRoomsScreenState(); -} - -class _ChatRoomsScreenState extends ConsumerState - with SingleTickerProviderStateMixin { - late TabController _tabController; - - @override - void initState() { - super.initState(); - _tabController = TabController(length: 2, vsync: this); - } - - @override - void dispose() { - _tabController.dispose(); - super.dispose(); - } - - @override - Widget build(BuildContext context) { - final chatListState = ref.watch(chatRoomsNotifierProvider); + Widget build(BuildContext context, WidgetRef ref) { + final currentTab = ref.watch(chatTabProvider); return Scaffold( backgroundColor: AppTheme.backgroundDark, @@ -56,7 +37,9 @@ class _ChatRoomsScreenState extends ConsumerState decoration: BoxDecoration( color: AppTheme.backgroundDark, border: Border( - bottom: BorderSide(color: Colors.white.withValues(alpha: 0.1), width: 0.5), + bottom: BorderSide( + color: Colors.white.withValues(alpha: 0.1), + width: 0.5), ), ), child: Text( @@ -69,12 +52,7 @@ class _ChatRoomsScreenState extends ConsumerState ), ), // Tab bar - ChatTabs( - tabController: _tabController, - onTabChanged: () { - setState(() {}); - }, - ), + ChatTabs(currentTab: currentTab), // Description text Container( width: double.infinity, @@ -82,31 +60,38 @@ class _ChatRoomsScreenState extends ConsumerState decoration: BoxDecoration( color: AppTheme.backgroundDark, border: Border( - bottom: BorderSide(color: Colors.white.withValues(alpha: 0.1), width: 0.5), + bottom: BorderSide( + color: Colors.white.withValues(alpha: 0.1), + width: 0.5), ), ), child: Text( - S.of(context)?.conversationsDescription ?? 'Your conversations with other users will appear here.', + _getTabDescription(context, currentTab), style: TextStyle( color: AppTheme.textSecondary, fontSize: 14, ), ), ), - // Content area + // Content area with gesture detection Expanded( - child: Container( - color: AppTheme.backgroundDark, - child: TabBarView( - controller: _tabController, - children: [ - // Messages tab - _buildBody(context, chatListState), - // Disputes tab (placeholder for now) - EmptyStateView( - message: S.of(context)?.noDisputesAvailable ?? 'No disputes available', - ), - ], + child: GestureDetector( + onHorizontalDragEnd: (details) { + if (details.primaryVelocity != null && + details.primaryVelocity! < 0) { + // Swipe left - go to disputes + ref.read(chatTabProvider.notifier).state = ChatTabType.disputes; + } else if (details.primaryVelocity != null && + details.primaryVelocity! > 0) { + // Swipe right - go to messages + ref.read(chatTabProvider.notifier).state = ChatTabType.messages; + } + }, + child: Container( + color: AppTheme.backgroundDark, + child: currentTab == ChatTabType.messages + ? _buildBody(context, ref) + : const DisputesList(), ), ), ), @@ -128,19 +113,16 @@ class _ChatRoomsScreenState extends ConsumerState ); } - Widget _buildBody(BuildContext context, List state) { - if (state.isEmpty) { - return EmptyStateView( - message: S.of(context)?.noMessagesAvailable ?? 'No messages available', - ); - } - - - + Widget _buildBody(BuildContext context, WidgetRef ref) { // Use the optimized provider that returns sorted chat rooms with fresh data // This prevents excessive rebuilds by memoizing the sorted list final chatRoomsWithFreshData = ref.watch(sortedChatRoomsProvider); + if (chatRoomsWithFreshData.isEmpty) { + return EmptyStateView( + message: S.of(context)?.noMessagesAvailable ?? 'No messages available', + ); + } return ListView.builder( itemCount: chatRoomsWithFreshData.length, @@ -154,5 +136,15 @@ class _ChatRoomsScreenState extends ConsumerState ); } - + String _getTabDescription(BuildContext context, ChatTabType currentTab) { + if (currentTab == ChatTabType.messages) { + // Messages tab + return S.of(context)?.conversationsDescription ?? + 'Here you\'ll find your conversations with other users during trades.'; + } else { + // Disputes tab + return S.of(context)?.disputesDescription ?? + 'These are your open disputes and the chats with the admin helping resolve them.'; + } + } } diff --git a/lib/features/chat/widgets/chat_tabs.dart b/lib/features/chat/widgets/chat_tabs.dart index 283bbaf6..16ade4ae 100644 --- a/lib/features/chat/widgets/chat_tabs.dart +++ b/lib/features/chat/widgets/chat_tabs.dart @@ -1,19 +1,19 @@ import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:mostro_mobile/core/app_theme.dart'; +import 'package:mostro_mobile/features/chat/providers/chat_tab_provider.dart'; import 'package:mostro_mobile/generated/l10n.dart'; -class ChatTabs extends StatelessWidget { - final TabController tabController; - final VoidCallback onTabChanged; +class ChatTabs extends ConsumerWidget { + final ChatTabType currentTab; const ChatTabs({ super.key, - required this.tabController, - required this.onTabChanged, + required this.currentTab, }); @override - Widget build(BuildContext context) { + Widget build(BuildContext context, WidgetRef ref) { return Container( decoration: BoxDecoration( color: AppTheme.backgroundDark, @@ -26,20 +26,19 @@ class ChatTabs extends StatelessWidget { ), child: Row( children: [ - _buildTabButton(context, 0, S.of(context)!.messages, tabController.index == 0), - _buildTabButton(context, 1, S.of(context)!.disputes, tabController.index == 1), + _buildTabButton(context, ref, ChatTabType.messages, S.of(context)!.messages, currentTab == ChatTabType.messages), + _buildTabButton(context, ref, ChatTabType.disputes, S.of(context)!.disputes, currentTab == ChatTabType.disputes), ], ), ); } Widget _buildTabButton( - BuildContext context, int index, String text, bool isActive) { + BuildContext context, WidgetRef ref, ChatTabType tabType, String text, bool isActive) { return Expanded( child: InkWell( onTap: () { - tabController.animateTo(index); - onTabChanged(); + ref.read(chatTabProvider.notifier).state = tabType; }, child: Container( padding: const EdgeInsets.symmetric(vertical: 16), diff --git a/lib/features/chat/widgets/trade_information_tab.dart b/lib/features/chat/widgets/trade_information_tab.dart index bdd442a9..8f3f7630 100644 --- a/lib/features/chat/widgets/trade_information_tab.dart +++ b/lib/features/chat/widgets/trade_information_tab.dart @@ -132,7 +132,7 @@ class TradeInformationTab extends StatelessWidget { ), const SizedBox(height: 8), Text( - S.of(context)!.forAmount(order!.fiatAmount.toString(), order!.fiatCode), + S.of(context)!.forAmountWithCurrency(order!.fiatAmount.toString(), order!.fiatCode), style: TextStyle( color: AppTheme.textSecondary, fontSize: 14, diff --git a/lib/features/disputes/data/dispute_mock_data.dart b/lib/features/disputes/data/dispute_mock_data.dart new file mode 100644 index 00000000..0ff81dae --- /dev/null +++ b/lib/features/disputes/data/dispute_mock_data.dart @@ -0,0 +1,147 @@ +import 'package:flutter/foundation.dart'; +import 'package:mostro_mobile/data/models/dispute_chat.dart'; +import 'package:mostro_mobile/data/models/dispute.dart'; + +/// Mock data for disputes UI development and testing +/// This file can be easily removed when real dispute data is implemented +class DisputeMockData { + + /// Mock dispute list for the disputes screen - only available in debug mode + static List get mockDisputes => kDebugMode ? [ + DisputeData( + disputeId: 'dispute_001', + orderId: 'order_123', + createdAt: DateTime.now().subtract(const Duration(minutes: 30)), + status: 'initiated', + descriptionKey: DisputeDescriptionKey.initiatedByUser, + counterparty: null, + isCreator: true, + userRole: UserRole.buyer, + ), + DisputeData( + disputeId: 'dispute_002', + orderId: 'order_456', + createdAt: DateTime.now().subtract(const Duration(hours: 6)), + status: 'in-progress', + descriptionKey: DisputeDescriptionKey.inProgress, + counterparty: 'admin_123', + isCreator: false, + userRole: UserRole.seller, + ), + DisputeData( + disputeId: 'dispute_003', + orderId: 'order_789', + createdAt: DateTime.now().subtract(const Duration(days: 2)), + status: 'resolved', + descriptionKey: DisputeDescriptionKey.resolved, + counterparty: 'admin_123', + isCreator: null, // Unknown creator state for resolved dispute + userRole: UserRole.buyer, + ), + ] : []; + + /// Mock dispute details based on dispute ID + static DisputeData? getDisputeById(String disputeId) { + try { + return mockDisputes.firstWhere( + (dispute) => dispute.disputeId == disputeId, + ); + } catch (e) { + return _getDefaultMockDispute(disputeId); + } + } + + /// Mock chat messages based on dispute status + static List getMockMessages(String disputeId, String status) { + // If dispute is in initiated state, show no messages (waiting for admin) + if (status == 'initiated') { + return []; + } + + if (status == 'resolved') { + return [ + DisputeChat( + id: '1', + message: 'Hello, I need help with this order. The seller hasn\'t responded to my messages.', + timestamp: DateTime.now().subtract(const Duration(days: 3, hours: 2)), + isFromUser: true, + ), + DisputeChat( + id: '2', + message: 'I understand your concern. Let me review the order details and contact the seller.', + timestamp: DateTime.now().subtract(const Duration(days: 3, hours: 1, minutes: 45)), + isFromUser: false, + adminPubkey: 'admin_123', + ), + DisputeChat( + id: '3', + message: 'I\'ve contacted the seller and they confirmed they will complete the payment within 2 hours.', + timestamp: DateTime.now().subtract(const Duration(days: 2, hours: 12)), + isFromUser: false, + adminPubkey: 'admin_123', + ), + DisputeChat( + id: '4', + message: 'Thank you for your help. I\'ll wait for the payment.', + timestamp: DateTime.now().subtract(const Duration(days: 2, hours: 11, minutes: 30)), + isFromUser: true, + ), + ]; + } else { + // in-progress status + return [ + DisputeChat( + id: '1', + message: 'Hello, I need help with this order. The seller hasn\'t responded to my messages.', + timestamp: DateTime.now().subtract(const Duration(hours: 2)), + isFromUser: true, + ), + DisputeChat( + id: '2', + message: 'I understand your concern. Let me review the order details and contact the seller.', + timestamp: DateTime.now().subtract(const Duration(hours: 1, minutes: 45)), + isFromUser: false, + adminPubkey: 'admin_123', + ), + DisputeChat( + id: '3', + message: 'Thank you for your patience. I\'m working on resolving this issue.', + timestamp: DateTime.now().subtract(const Duration(minutes: 30)), + isFromUser: false, + adminPubkey: 'admin_123', + ), + ]; + } + } + + /// Creates a default mock dispute for unknown IDs + static DisputeData _getDefaultMockDispute(String disputeId) { + return DisputeData( + disputeId: disputeId, + orderId: 'order_unknown', + createdAt: DateTime.now().subtract(const Duration(hours: 1)), + status: 'in-progress', + descriptionKey: DisputeDescriptionKey.inProgress, + counterparty: 'admin_123', + isCreator: null, // Unknown creator state for default mock + userRole: UserRole.buyer, + ); + } + + /// Mock dispute creation - returns a new dispute ID + static String createMockDispute({ + required String orderId, + required String reason, + required String initiatorRole, + required Map orderDetails, + }) { + final newDisputeId = 'dispute_${DateTime.now().millisecondsSinceEpoch}'; + + // In a real implementation, this would save to database + // For now, we just return the ID + return newDisputeId; + } + + /// Check if mock data is enabled (only in debug mode) + static bool get isMockEnabled => kDebugMode; +} \ No newline at end of file diff --git a/lib/features/disputes/notifiers/dispute_chat_notifier.dart b/lib/features/disputes/notifiers/dispute_chat_notifier.dart new file mode 100644 index 00000000..15b175c5 --- /dev/null +++ b/lib/features/disputes/notifiers/dispute_chat_notifier.dart @@ -0,0 +1,46 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:mostro_mobile/data/models/dispute_chat.dart'; + +/// Stub notifier for DisputeChat - UI only implementation +class DisputeChatNotifier extends StateNotifier> { + DisputeChatNotifier() : super([]); + + void sendMessage(String message) { + // Stub implementation - just add a mock message + final newMessage = DisputeChat( + id: DateTime.now().millisecondsSinceEpoch.toString(), + message: message, + timestamp: DateTime.now(), + isFromUser: true, + ); + state = [...state, newMessage]; + } + + void loadMessages(String disputeId) { + // Stub implementation - load mock messages + state = [ + DisputeChat( + id: '1', + message: 'Hello, I have an issue with this order', + timestamp: DateTime.now().subtract(const Duration(minutes: 5)), + isFromUser: true, + ), + DisputeChat( + id: '2', + message: 'I understand. Let me review the details.', + timestamp: DateTime.now().subtract(const Duration(minutes: 3)), + isFromUser: false, + adminPubkey: 'admin_pubkey_123', + ), + ]; + } +} + +final disputeChatNotifierProvider = + StateNotifierProvider.family, String>( + (ref, disputeId) { + final notifier = DisputeChatNotifier(); + notifier.loadMessages(disputeId); + return notifier; + }, +); \ No newline at end of file diff --git a/lib/features/disputes/providers/dispute_providers.dart b/lib/features/disputes/providers/dispute_providers.dart new file mode 100644 index 00000000..2ab4a577 --- /dev/null +++ b/lib/features/disputes/providers/dispute_providers.dart @@ -0,0 +1,71 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:mostro_mobile/data/models/dispute.dart'; +import 'package:mostro_mobile/data/models/dispute_chat.dart'; +import 'package:mostro_mobile/features/disputes/notifiers/dispute_chat_notifier.dart'; +import 'package:mostro_mobile/features/disputes/data/dispute_mock_data.dart'; + +/// Provider for dispute details - uses mock data when enabled +final disputeDetailsProvider = FutureProvider.family((ref, disputeId) async { + // Simulate loading time + await Future.delayed(const Duration(milliseconds: 300)); + + if (!DisputeMockData.isMockEnabled) { + // TODO: Implement real dispute loading here + return null; + } + + // Get mock dispute data + final disputeData = DisputeMockData.getDisputeById(disputeId); + if (disputeData == null) return null; + + return Dispute( + disputeId: disputeData.disputeId, + orderId: disputeData.orderId, + status: disputeData.status, + createdAt: disputeData.createdAt, + action: _getActionFromStatus(disputeData.status, disputeData.userRole.name), + adminPubkey: disputeData.status != 'initiated' ? 'admin_123' : null, + ); +}); + +/// Helper function to convert status to action +String _getActionFromStatus(String status, String initiatorRole) { + switch (status) { + case 'initiated': + return 'dispute-initiated-by-you'; + case 'in-progress': + return initiatorRole == 'buyer' ? 'dispute-initiated-by-you' : 'dispute-initiated-by-peer'; + case 'resolved': + return 'dispute-resolved'; + default: + return 'dispute-initiated-by-you'; + } +} + +/// Stub provider for dispute chat messages - UI only implementation +final disputeChatProvider = StateNotifierProvider.family, String>( + (ref, disputeId) { + return ref.watch(disputeChatNotifierProvider(disputeId).notifier); + }, +); + +/// Provider for user disputes list - uses mock data when enabled +final userDisputesProvider = FutureProvider>((ref) async { + // Simulate loading time + await Future.delayed(const Duration(milliseconds: 500)); + + if (!DisputeMockData.isMockEnabled) { + // TODO: Implement real disputes loading here + return []; + } + + // Convert mock dispute data to Dispute objects + return DisputeMockData.mockDisputes.map((disputeData) => Dispute( + disputeId: disputeData.disputeId, + orderId: disputeData.orderId, + status: disputeData.status, + createdAt: disputeData.createdAt, + action: _getActionFromStatus(disputeData.status, disputeData.userRole.name), + adminPubkey: disputeData.status != 'initiated' ? 'admin_123' : null, + )).toList(); +}); \ No newline at end of file diff --git a/lib/features/disputes/screens/dispute_chat_screen.dart b/lib/features/disputes/screens/dispute_chat_screen.dart new file mode 100644 index 00000000..ce6fad42 --- /dev/null +++ b/lib/features/disputes/screens/dispute_chat_screen.dart @@ -0,0 +1,73 @@ +import 'package:flutter/material.dart'; +import 'package:mostro_mobile/features/disputes/widgets/dispute_communication_section.dart'; +import 'package:mostro_mobile/features/disputes/widgets/dispute_message_input.dart'; +import 'package:mostro_mobile/features/disputes/data/dispute_mock_data.dart'; +import 'package:mostro_mobile/core/app_theme.dart'; + +class DisputeChatScreen extends StatelessWidget { + final String disputeId; + + const DisputeChatScreen({ + super.key, + required this.disputeId, + }); + + @override + Widget build(BuildContext context) { + // Get dispute data from mock file + final mockDispute = DisputeMockData.isMockEnabled + ? DisputeMockData.getDisputeById(disputeId) + : null; + + // Fallback if no mock data found + if (mockDispute == null) { + return Scaffold( + appBar: AppBar( + title: const Text('Dispute Chat'), + backgroundColor: AppTheme.backgroundDark, + ), + backgroundColor: AppTheme.backgroundDark, + body: const Center( + child: Text( + 'Dispute not found', + style: TextStyle(color: Colors.white), + ), + ), + ); + } + + return Scaffold( + backgroundColor: AppTheme.backgroundDark, + appBar: AppBar( + backgroundColor: AppTheme.backgroundDark, + elevation: 0, + title: Text( + 'Dispute Chat', + style: const TextStyle( + color: Colors.white, + fontSize: 18, + fontWeight: FontWeight.w600, + ), + ), + leading: IconButton( + icon: const Icon(Icons.arrow_back, color: Colors.white), + onPressed: () => Navigator.of(context).pop(), + ), + ), + body: Column( + children: [ + // Communication section with messages (includes info card in scroll) + DisputeCommunicationSection( + disputeId: disputeId, + disputeData: mockDispute, + status: mockDispute.status, + ), + + // Input section for sending messages (only show if not resolved and not initiated) + if (mockDispute.status != 'resolved' && mockDispute.status != 'initiated') + DisputeMessageInput(disputeId: disputeId), + ], + ), + ); + } +} diff --git a/lib/features/disputes/widgets/dispute_communication_section.dart b/lib/features/disputes/widgets/dispute_communication_section.dart new file mode 100644 index 00000000..4b7e30de --- /dev/null +++ b/lib/features/disputes/widgets/dispute_communication_section.dart @@ -0,0 +1,27 @@ +import 'package:flutter/material.dart'; +import 'package:mostro_mobile/features/disputes/widgets/dispute_messages_list.dart'; +import 'package:mostro_mobile/data/models/dispute.dart'; + +class DisputeCommunicationSection extends StatelessWidget { + final String disputeId; + final String status; + final DisputeData disputeData; + + const DisputeCommunicationSection({ + super.key, + required this.disputeId, + required this.disputeData, + this.status = 'in-progress', + }); + + @override + Widget build(BuildContext context) { + return Expanded( + child: DisputeMessagesList( + disputeId: disputeId, + status: status, + disputeData: disputeData, // Pass dispute data to include in scroll + ), + ); + } +} \ No newline at end of file diff --git a/lib/features/disputes/widgets/dispute_content.dart b/lib/features/disputes/widgets/dispute_content.dart new file mode 100644 index 00000000..acfc886b --- /dev/null +++ b/lib/features/disputes/widgets/dispute_content.dart @@ -0,0 +1,26 @@ +import 'package:flutter/material.dart'; +import 'package:mostro_mobile/features/disputes/widgets/dispute_header.dart'; +import 'package:mostro_mobile/features/disputes/widgets/dispute_order_id.dart'; +import 'package:mostro_mobile/features/disputes/widgets/dispute_description.dart'; +import 'package:mostro_mobile/data/models/dispute.dart'; + +/// Main content widget for dispute information +class DisputeContent extends StatelessWidget { + final DisputeData dispute; + + const DisputeContent({super.key, required this.dispute}); + + @override + Widget build(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + DisputeHeader(dispute: dispute), + const SizedBox(height: 4), + DisputeOrderId(orderId: dispute.orderIdDisplay), + const SizedBox(height: 2), + DisputeDescription(description: dispute.description), + ], + ); + } +} diff --git a/lib/features/disputes/widgets/dispute_description.dart b/lib/features/disputes/widgets/dispute_description.dart new file mode 100644 index 00000000..5d08ea7d --- /dev/null +++ b/lib/features/disputes/widgets/dispute_description.dart @@ -0,0 +1,22 @@ +import 'package:flutter/material.dart'; +import 'package:mostro_mobile/core/app_theme.dart'; + +/// Description widget for dispute list items +class DisputeDescription extends StatelessWidget { + final String description; + + const DisputeDescription({super.key, required this.description}); + + @override + Widget build(BuildContext context) { + return Text( + description, + style: TextStyle( + color: AppTheme.textSecondary, + fontSize: 13, + ), + overflow: TextOverflow.ellipsis, + maxLines: 1, + ); + } +} diff --git a/lib/features/disputes/widgets/dispute_header.dart b/lib/features/disputes/widgets/dispute_header.dart new file mode 100644 index 00000000..07d4f2e0 --- /dev/null +++ b/lib/features/disputes/widgets/dispute_header.dart @@ -0,0 +1,29 @@ +import 'package:flutter/material.dart'; +import 'package:mostro_mobile/features/disputes/widgets/dispute_status_badge.dart'; +import 'package:mostro_mobile/generated/l10n.dart'; +import 'package:mostro_mobile/data/models/dispute.dart'; + +/// Header widget with title and status badge for dispute list items +class DisputeHeader extends StatelessWidget { + final DisputeData dispute; + + const DisputeHeader({super.key, required this.dispute}); + + @override + Widget build(BuildContext context) { + return Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + S.of(context)?.disputeForOrder ?? 'Dispute for order', + style: const TextStyle( + color: Colors.white, + fontWeight: FontWeight.bold, + fontSize: 16, + ), + ), + DisputeStatusBadge(status: dispute.status), + ], + ); + } +} diff --git a/lib/features/disputes/widgets/dispute_icon.dart b/lib/features/disputes/widgets/dispute_icon.dart new file mode 100644 index 00000000..07e23ea3 --- /dev/null +++ b/lib/features/disputes/widgets/dispute_icon.dart @@ -0,0 +1,18 @@ +import 'package:flutter/material.dart'; + +/// Warning icon widget for dispute list items +class DisputeIcon extends StatelessWidget { + const DisputeIcon({super.key}); + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.only(top: 8.0), + child: Icon( + Icons.warning_amber, + color: Colors.amber, + size: 32, + ), + ); + } +} diff --git a/lib/features/disputes/widgets/dispute_info_card.dart b/lib/features/disputes/widgets/dispute_info_card.dart new file mode 100644 index 00000000..71c899d5 --- /dev/null +++ b/lib/features/disputes/widgets/dispute_info_card.dart @@ -0,0 +1,98 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:mostro_mobile/core/app_theme.dart'; +import 'package:mostro_mobile/features/disputes/widgets/dispute_status_badge.dart'; +import 'package:mostro_mobile/features/disputes/widgets/dispute_status_content.dart'; +import 'package:mostro_mobile/data/models/dispute.dart'; +import 'package:mostro_mobile/shared/providers/legible_handle_provider.dart'; +import 'package:mostro_mobile/generated/l10n.dart'; + +class DisputeInfoCard extends ConsumerWidget { + final DisputeData dispute; + + const DisputeInfoCard({ + super.key, + required this.dispute, + }); + + @override + Widget build(BuildContext context, WidgetRef ref) { + // Resolve counterparty pubkey to readable nym + final counterpartyNym = dispute.counterpartyDisplay != S.of(context)!.unknown + ? ref.watch(nickNameProvider(dispute.counterpartyDisplay)) + : S.of(context)!.unknown; + + return Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: AppTheme.dark1, + borderRadius: BorderRadius.circular(12), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Expanded( + child: Text( + S.of(context)!.disputeWith( + dispute.userIsBuyer ? S.of(context)!.seller : S.of(context)!.buyer, + counterpartyNym, + ), + style: const TextStyle( + color: Colors.white, + fontSize: 18, + fontWeight: FontWeight.w600, + ), + ), + ), + DisputeStatusBadge(status: dispute.status), + ], + ), + const SizedBox(height: 16), + + // Order ID + _buildInfoRow(context, S.of(context)!.orderIdLabel, dispute.orderIdDisplay), + const SizedBox(height: 8), + + // Dispute ID + _buildInfoRow(context, S.of(context)!.disputeIdLabel, dispute.disputeId), + const SizedBox(height: 16), + + // Dispute description - conditional based on status + DisputeStatusContent(dispute: dispute), + ], + ), + ); + } + + Widget _buildInfoRow(BuildContext context, String label, String value) { + return Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SizedBox( + width: 80, + child: Text( + label, + style: TextStyle( + color: AppTheme.textSecondary, + fontSize: 14, + ), + ), + ), + const SizedBox(width: 12), + Expanded( + child: Text( + value, + style: const TextStyle( + color: Colors.white, + fontSize: 14, + fontFamily: 'monospace', + ), + ), + ), + ], + ); + } +} diff --git a/lib/features/disputes/widgets/dispute_input_section.dart b/lib/features/disputes/widgets/dispute_input_section.dart new file mode 100644 index 00000000..bdf4ecca --- /dev/null +++ b/lib/features/disputes/widgets/dispute_input_section.dart @@ -0,0 +1,154 @@ +import 'package:flutter/material.dart'; +import 'package:mostro_mobile/core/app_theme.dart'; +import 'package:mostro_mobile/generated/l10n.dart'; + +class DisputeInputSection extends StatefulWidget { + final String disputeId; + + const DisputeInputSection({ + super.key, + required this.disputeId, + }); + + @override + State createState() => _DisputeInputSectionState(); +} + +class _DisputeInputSectionState extends State { + final TextEditingController _messageController = TextEditingController(); + bool _isLoading = false; + + @override + void dispose() { + _messageController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.all(16.0), + decoration: BoxDecoration( + color: AppTheme.backgroundDark, + border: Border( + top: BorderSide( + color: Colors.white.withValues(alpha: 0.05), + width: 1.0, + ), + ), + ), + child: SafeArea( + child: Row( + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + Expanded( + child: Container( + constraints: const BoxConstraints( + minHeight: 40, + maxHeight: 120, + ), + decoration: BoxDecoration( + color: Colors.grey[850], + borderRadius: BorderRadius.circular(20), + border: Border.all( + color: Colors.white.withValues(alpha: 0.1), + width: 1, + ), + ), + child: TextField( + controller: _messageController, + maxLines: null, + style: const TextStyle( + color: Colors.white, + fontSize: 16, + ), + decoration: InputDecoration( + hintText: S.of(context)?.typeYourMessage ?? 'Type your message...', + hintStyle: TextStyle( + color: AppTheme.textSecondary, + fontSize: 16, + ), + border: InputBorder.none, + contentPadding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 10, + ), + ), + ), + ), + ), + const SizedBox(width: 8), + GestureDetector( + onTap: _isLoading ? null : _sendMessage, + child: Container( + width: 40, + height: 40, + decoration: BoxDecoration( + color: _canSend() ? Colors.blue : Colors.grey[600], + shape: BoxShape.circle, + ), + child: _isLoading + ? Padding( + padding: const EdgeInsets.all(8.0), + child: CircularProgressIndicator( + color: Colors.white, + strokeWidth: 2, + ), + ) + : const Icon( + Icons.send, + color: Colors.white, + size: 20, + ), + ), + ), + ], + ), + ), + ); + } + + bool _canSend() { + return _messageController.text.trim().isNotEmpty && !_isLoading; + } + + void _sendMessage() async { + if (!_canSend()) return; + + final message = _messageController.text.trim(); + _messageController.clear(); + + setState(() { + _isLoading = true; + }); + + try { + // Mock sending - just simulate delay + await Future.delayed(const Duration(milliseconds: 500)); + + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('Message sent: $message'), + backgroundColor: Colors.green, + ), + ); + } + } catch (error) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('Failed to send message: $error'), + backgroundColor: Colors.red, + ), + ); + } + } finally { + if (mounted) { + setState(() { + _isLoading = false; + }); + } + } + } +} \ No newline at end of file diff --git a/lib/features/disputes/widgets/dispute_list_item.dart b/lib/features/disputes/widgets/dispute_list_item.dart new file mode 100644 index 00000000..14392047 --- /dev/null +++ b/lib/features/disputes/widgets/dispute_list_item.dart @@ -0,0 +1,45 @@ +import 'package:flutter/material.dart'; +import 'package:mostro_mobile/features/disputes/widgets/dispute_icon.dart'; +import 'package:mostro_mobile/features/disputes/widgets/dispute_content.dart'; +import 'package:mostro_mobile/data/models/dispute.dart'; + +class DisputeListItem extends StatelessWidget { + final DisputeData dispute; + final VoidCallback onTap; + + const DisputeListItem({ + super.key, + required this.dispute, + required this.onTap, + }); + + @override + Widget build(BuildContext context) { + return GestureDetector( + onTap: onTap, + child: Container( + decoration: BoxDecoration( + border: Border( + bottom: BorderSide( + color: Colors.white.withValues(alpha: 0.05), + width: 1.0, + ), + ), + ), + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 16, horizontal: 16), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + DisputeIcon(), + const SizedBox(width: 16), + Expanded( + child: DisputeContent(dispute: dispute), + ), + ], + ), + ), + ), + ); + } +} diff --git a/lib/features/disputes/widgets/dispute_message_bubble.dart b/lib/features/disputes/widgets/dispute_message_bubble.dart new file mode 100644 index 00000000..cc4e710f --- /dev/null +++ b/lib/features/disputes/widgets/dispute_message_bubble.dart @@ -0,0 +1,117 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:mostro_mobile/core/app_theme.dart'; +import 'package:mostro_mobile/generated/l10n.dart'; + +class DisputeMessageBubble extends StatelessWidget { + final String message; + final bool isFromUser; + final DateTime timestamp; + final String? adminPubkey; + + const DisputeMessageBubble({ + super.key, + required this.message, + required this.isFromUser, + required this.timestamp, + this.adminPubkey, + }); + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.symmetric(vertical: 4, horizontal: 16), + alignment: isFromUser ? Alignment.centerRight : Alignment.centerLeft, + child: Row( + mainAxisAlignment: isFromUser ? MainAxisAlignment.end : MainAxisAlignment.start, + children: [ + Flexible( + child: ConstrainedBox( + constraints: BoxConstraints( + maxWidth: MediaQuery.of(context).size.width * 0.75, // Max 75% of screen width + minWidth: 0, + ), + child: GestureDetector( + onLongPress: () => _copyToClipboard(context, message), + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + decoration: BoxDecoration( + color: isFromUser ? AppTheme.purpleAccent : _getAdminMessageColor(), + borderRadius: BorderRadius.only( + topLeft: const Radius.circular(16), + topRight: const Radius.circular(16), + bottomLeft: Radius.circular(isFromUser ? 16 : 4), + bottomRight: Radius.circular(isFromUser ? 4 : 16), + ), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + message, + style: const TextStyle( + color: AppTheme.cream1, + fontSize: 16, + height: 1.4, + ), + ), + const SizedBox(height: 6), + Text( + _formatTime(timestamp), + style: const TextStyle( + color: Colors.white70, + fontSize: 12, + ), + ), + ], + ), + ), + ), + ), + ), + ], + ), + ); + } + + Color _getAdminMessageColor() { + // Use admin blue color with same transparency approach as peer messages + const Color adminBlue = Color(0xFF1565C0); + + // Create a subdued version by reducing saturation and value like peer messages + final HSVColor hsvColor = HSVColor.fromColor(adminBlue); + + // Create a more subdued version with lower saturation and value + // but keep enough color to be recognizable (same logic as peer messages) + return hsvColor.withSaturation(0.3).withValue(0.25).toColor(); + } + + void _copyToClipboard(BuildContext context, String text) { + Clipboard.setData(ClipboardData(text: text)); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(S.of(context)?.messageCopiedToClipboard ?? 'Message copied to clipboard'), + duration: const Duration(seconds: 1), + backgroundColor: Colors.green, + ), + ); + } + + String _formatTime(DateTime dateTime) { + final now = DateTime.now(); + final difference = now.difference(dateTime); + + if (difference.inMinutes < 1) { + return 'now'; + } else if (difference.inMinutes < 60) { + return '${difference.inMinutes}m ago'; + } else if (difference.inHours < 24) { + return '${difference.inHours}h ago'; + } else if (difference.inDays < 7) { + return '${difference.inDays}d ago'; + } else { + // Format as date for older messages + return '${dateTime.day}/${dateTime.month}/${dateTime.year}'; + } + } +} \ No newline at end of file diff --git a/lib/features/disputes/widgets/dispute_message_input.dart b/lib/features/disputes/widgets/dispute_message_input.dart new file mode 100644 index 00000000..86110eb4 --- /dev/null +++ b/lib/features/disputes/widgets/dispute_message_input.dart @@ -0,0 +1,129 @@ +import 'package:flutter/material.dart'; +import 'package:mostro_mobile/core/app_theme.dart'; +import 'package:mostro_mobile/generated/l10n.dart'; + +class DisputeMessageInput extends StatefulWidget { + final String disputeId; + + const DisputeMessageInput({ + super.key, + required this.disputeId, + }); + + @override + State createState() => _DisputeMessageInputState(); +} + +class _DisputeMessageInputState extends State { + final TextEditingController _textController = TextEditingController(); + final FocusNode _focusNode = FocusNode(); + + @override + void dispose() { + _focusNode.dispose(); + _textController.dispose(); + super.dispose(); + } + + void _sendMessage() { + final text = _textController.text.trim(); + if (text.isNotEmpty) { + // Mock sending - just simulate with a snackbar + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('Message sent: $text'), + backgroundColor: Colors.green, + duration: const Duration(seconds: 1), + ), + ); + _textController.clear(); + } + } + + @override + Widget build(BuildContext context) { + return Container( + decoration: BoxDecoration( + color: AppTheme.backgroundDark, + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.1), + blurRadius: 4, + offset: const Offset(0, -1), + ), + ], + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Padding( + padding: EdgeInsets.only( + left: 16, + right: 16, + top: 12, + bottom: 12 + MediaQuery.of(context).padding.bottom, + ), + child: Row( + children: [ + Expanded( + child: Container( + decoration: BoxDecoration( + color: AppTheme.backgroundInput, + borderRadius: BorderRadius.circular(24), + ), + child: TextField( + controller: _textController, + focusNode: _focusNode, + enabled: true, + style: TextStyle( + color: AppTheme.cream1, + fontSize: 15, + ), + decoration: InputDecoration( + hintText: S.of(context)!.typeAMessage, + hintStyle: TextStyle( + color: AppTheme.textSecondary.withValues(alpha: 153), // 0.6 opacity + fontSize: 15), + contentPadding: + const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + border: InputBorder.none, + enabledBorder: InputBorder.none, + focusedBorder: InputBorder.none, + errorBorder: InputBorder.none, + disabledBorder: InputBorder.none, + isDense: true, + ), + textCapitalization: TextCapitalization.sentences, + keyboardType: TextInputType.text, + textInputAction: TextInputAction.send, + onSubmitted: (_) => _sendMessage(), + ), + ), + ), + const SizedBox(width: 12), + Container( + width: 42, + height: 42, + decoration: BoxDecoration( + color: AppTheme.mostroGreen, + shape: BoxShape.circle, + ), + child: IconButton( + icon: Icon( + Icons.send, + color: Colors.white, + size: 20, + ), + onPressed: _sendMessage, + padding: EdgeInsets.zero, + constraints: const BoxConstraints(), + ), + ), + ], + ), + ), + ], + ), + ); + } +} \ No newline at end of file diff --git a/lib/features/disputes/widgets/dispute_messages_list.dart b/lib/features/disputes/widgets/dispute_messages_list.dart new file mode 100644 index 00000000..c0654916 --- /dev/null +++ b/lib/features/disputes/widgets/dispute_messages_list.dart @@ -0,0 +1,222 @@ +import 'package:flutter/material.dart'; +import 'package:mostro_mobile/core/app_theme.dart'; +import 'package:mostro_mobile/data/models/dispute_chat.dart'; +import 'package:mostro_mobile/data/models/dispute.dart'; +import 'package:mostro_mobile/features/disputes/widgets/dispute_message_bubble.dart'; +import 'package:mostro_mobile/features/disputes/widgets/dispute_info_card.dart'; +import 'package:mostro_mobile/features/disputes/data/dispute_mock_data.dart'; +import 'package:mostro_mobile/generated/l10n.dart'; + +class DisputeMessagesList extends StatefulWidget { + final String disputeId; + final String status; + final DisputeData disputeData; + final ScrollController? scrollController; + + const DisputeMessagesList({ + super.key, + required this.disputeId, + required this.status, + required this.disputeData, + this.scrollController, + }); + + @override + State createState() => _DisputeMessagesListState(); +} + +class _DisputeMessagesListState extends State { + late ScrollController _scrollController; + + @override + void initState() { + super.initState(); + _scrollController = widget.scrollController ?? ScrollController(); + + // Scroll to bottom on first load + WidgetsBinding.instance.addPostFrameCallback((_) { + _scrollToBottom(animate: false); + }); + } + + @override + void dispose() { + if (widget.scrollController == null) { + _scrollController.dispose(); + } + super.dispose(); + } + + void _scrollToBottom({bool animate = true}) { + if (!_scrollController.hasClients) return; + + if (animate) { + _scrollController.animateTo( + _scrollController.position.maxScrollExtent, + duration: const Duration(milliseconds: 300), + curve: Curves.easeOut, + ); + } else { + _scrollController.jumpTo(_scrollController.position.maxScrollExtent); + } + } + + @override + Widget build(BuildContext context) { + // Generate mock messages based on status + final messages = _getMockMessages(); + + return Container( + color: AppTheme.backgroundDark, + child: Column( + children: [ + // Admin assignment notification (if applicable) + _buildAdminAssignmentNotification(context), + + // Messages list with info card at top (scrolleable) + Expanded( + child: messages.isEmpty + ? Column( + children: [ + DisputeInfoCard(dispute: widget.disputeData), + Expanded(child: _buildWaitingForAdmin(context)), + ], + ) + : ListView.builder( + controller: _scrollController, + padding: const EdgeInsets.symmetric(vertical: 8), + itemCount: messages.length + 1, // +1 for info card + itemBuilder: (context, index) { + if (index == 0) { + // First item is the dispute info card + return DisputeInfoCard(dispute: widget.disputeData); + } + + // Rest are messages (adjust index) + final message = messages[index - 1]; + return DisputeMessageBubble( + message: message.message, + isFromUser: message.isFromUser, + timestamp: message.timestamp, + adminPubkey: message.adminPubkey, + ); + }, + ), + ), + + // Resolution notification (if resolved) + if (widget.status == 'resolved') + _buildResolutionNotification(context), + ], + ), + ); + } + + Widget _buildWaitingForAdmin(BuildContext context) { + return Center( + child: Padding( + padding: const EdgeInsets.all(32), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text( + S.of(context)?.waitingAdminAssignment ?? 'Waiting for admin assignment', + style: TextStyle( + color: AppTheme.textSecondary, + fontSize: 18, + fontWeight: FontWeight.w600, + ), + textAlign: TextAlign.center, + ), + const SizedBox(height: 16), + Text( + S.of(context)?.waitingAdminDescription ?? 'Your dispute has been submitted. An admin will be assigned to help resolve this issue.', + style: TextStyle( + color: AppTheme.textInactive, + fontSize: 14, + ), + textAlign: TextAlign.center, + ), + ], + ), + ), + ); + } + + Widget _buildAdminAssignmentNotification(BuildContext context) { + // Only show admin assignment notification if not in initiated state + if (widget.status == 'initiated') { + return const SizedBox.shrink(); + } + + return Container( + margin: const EdgeInsets.all(16), + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: Colors.blue[900], + borderRadius: BorderRadius.circular(8), + ), + child: Row( + children: [ + Icon( + Icons.admin_panel_settings, + color: Colors.blue[300], + size: 16, + ), + const SizedBox(width: 8), + Expanded( + child: Text( + S.of(context)?.adminAssigned ?? 'Admin has been assigned to this dispute', + style: TextStyle( + color: Colors.blue[300], + fontSize: 12, + fontWeight: FontWeight.w500, + ), + ), + ), + ], + ), + ); + } + + Widget _buildResolutionNotification(BuildContext context) { + return Container( + margin: const EdgeInsets.all(16), + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: Colors.green[900], + borderRadius: BorderRadius.circular(8), + ), + child: Row( + children: [ + Icon( + Icons.check_circle, + color: Colors.green[300], + size: 16, + ), + const SizedBox(width: 8), + Expanded( + child: Text( + S.of(context)?.disputeResolvedMessage ?? 'This dispute has been resolved. Check your wallet for any refunds or payments.', + style: TextStyle( + color: Colors.green[300], + fontSize: 12, + fontWeight: FontWeight.w500, + ), + ), + ), + ], + ), + ); + } + + List _getMockMessages() { + if (!DisputeMockData.isMockEnabled) { + // TODO: Load real messages here when mock is disabled + return []; + } + + // Use mock data from the centralized mock file + return DisputeMockData.getMockMessages(widget.disputeId, widget.status); + } +} \ No newline at end of file diff --git a/lib/features/disputes/widgets/dispute_order_id.dart b/lib/features/disputes/widgets/dispute_order_id.dart new file mode 100644 index 00000000..faac9314 --- /dev/null +++ b/lib/features/disputes/widgets/dispute_order_id.dart @@ -0,0 +1,18 @@ +import 'package:flutter/material.dart'; + +/// Order ID widget for dispute list items +class DisputeOrderId extends StatelessWidget { + final String orderId; + + const DisputeOrderId({super.key, required this.orderId}); + + @override + Widget build(BuildContext context) { + return Text( + orderId, + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + fontWeight: FontWeight.w500, + ), + ); + } +} diff --git a/lib/features/disputes/widgets/dispute_status_badge.dart b/lib/features/disputes/widgets/dispute_status_badge.dart new file mode 100644 index 00000000..828d8c72 --- /dev/null +++ b/lib/features/disputes/widgets/dispute_status_badge.dart @@ -0,0 +1,90 @@ +import 'package:flutter/material.dart'; +import 'package:mostro_mobile/core/app_theme.dart'; +import 'package:mostro_mobile/generated/l10n.dart'; + +/// Status badge widget for dispute list items +class DisputeStatusBadge extends StatelessWidget { + final String status; + + const DisputeStatusBadge({super.key, required this.status}); + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.symmetric( + horizontal: 8, + vertical: 2 + ), + decoration: BoxDecoration( + color: _getStatusBackgroundColor(status), + borderRadius: BorderRadius.circular(12), + ), + child: Text( + _getStatusText(context, status), + style: TextStyle( + color: _getStatusTextColor(status), + fontSize: 12, + fontWeight: FontWeight.w500, + ), + ), + ); + } + + /// Normalizes status string by trimming, lowercasing, and replacing spaces/underscores with hyphens + String _normalizeStatus(String status) { + if (status.isEmpty) return ''; + // Trim, lowercase, and replace spaces/underscores with hyphens + return status.trim().toLowerCase().replaceAll(RegExp(r'[\s_]+'), '-'); + } + + Color _getStatusBackgroundColor(String status) { + final s = _normalizeStatus(status); + switch (s) { + case 'initiated': + return AppTheme.statusPendingBackground.withValues(alpha: 0.3); + case 'in-progress': + return AppTheme.statusSuccessBackground.withValues(alpha: 0.3); + case 'resolved': + case 'solved': + return Colors.blue.withValues(alpha: 0.3); + case 'closed': + return AppTheme.statusInactiveBackground.withValues(alpha: 0.3); + default: + return AppTheme.statusPendingBackground.withValues(alpha: 0.3); + } + } + + Color _getStatusTextColor(String status) { + final s = _normalizeStatus(status); + switch (s) { + case 'initiated': + return AppTheme.statusPendingText; + case 'in-progress': + return AppTheme.statusSuccessText; + case 'resolved': + case 'solved': + return Colors.blue; + case 'closed': + return AppTheme.statusInactiveText; + default: + return AppTheme.statusPendingText; + } + } + + String _getStatusText(BuildContext context, String status) { + final s = _normalizeStatus(status); + switch (s) { + case 'initiated': + return S.of(context)!.disputeStatusInitiated; + case 'in-progress': + return S.of(context)!.disputeStatusInProgress; + case 'resolved': + case 'solved': + return S.of(context)!.disputeStatusResolved; + case 'closed': + return S.of(context)!.disputeStatusClosed; + default: + return S.of(context)!.disputeStatusInitiated; + } + } +} diff --git a/lib/features/disputes/widgets/dispute_status_content.dart b/lib/features/disputes/widgets/dispute_status_content.dart new file mode 100644 index 00000000..21376d5c --- /dev/null +++ b/lib/features/disputes/widgets/dispute_status_content.dart @@ -0,0 +1,140 @@ +import 'package:flutter/material.dart'; +import 'package:mostro_mobile/core/app_theme.dart'; +import 'package:mostro_mobile/data/models/dispute.dart'; +import 'package:mostro_mobile/generated/l10n.dart'; + +class DisputeStatusContent extends StatelessWidget { + final DisputeData dispute; + + const DisputeStatusContent({ + super.key, + required this.dispute, + }); + + @override + Widget build(BuildContext context) { + bool isResolved = dispute.status.toLowerCase() == 'resolved' || dispute.status.toLowerCase() == 'closed'; + + if (isResolved) { + // Show resolution message for resolved/completed disputes + return Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: AppTheme.mostroGreen.withValues(alpha: 0.1), + borderRadius: BorderRadius.circular(12), + border: Border.all( + color: AppTheme.mostroGreen.withValues(alpha: 0.3), + width: 1, + ), + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Icon( + Icons.check_circle_outline, + color: AppTheme.mostroGreen, + size: 24, + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + S.of(context)!.disputeResolvedTitle, + style: TextStyle( + color: AppTheme.mostroGreen, + fontSize: 16, + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(height: 8), + Text( + S.of(context)!.disputeResolvedMessage, + style: const TextStyle( + color: Colors.white, + fontSize: 14, + height: 1.5, + ), + ), + ], + ), + ), + ], + ), + ); + } else { + // Show instructions for in-progress disputes + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + _getDisputeStatusText(context), + style: const TextStyle( + color: Colors.white, + fontSize: 14, + height: 1.5, + ), + ), + const SizedBox(height: 16), + _buildBulletPoint(S.of(context)!.disputeInstruction1), + _buildBulletPoint(S.of(context)!.disputeInstruction2), + _buildBulletPoint(S.of(context)!.disputeInstruction3), + _buildBulletPoint(S.of(context)!.disputeInstruction4(dispute.counterpartyDisplay)), + ], + ); + } + } + + Widget _buildBulletPoint(String text) { + return Padding( + padding: const EdgeInsets.only(bottom: 12), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Container( + margin: const EdgeInsets.only(top: 8), + width: 4, + height: 4, + decoration: BoxDecoration( + color: AppTheme.textSecondary, + shape: BoxShape.circle, + ), + ), + const SizedBox(width: 12), + Expanded( + child: Text( + text, + style: TextStyle( + color: AppTheme.textSecondary, + fontSize: 14, + height: 1.5, + ), + ), + ), + ], + ), + ); + } + + /// Get the appropriate localized text based on the dispute description key + String _getDisputeStatusText(BuildContext context) { + switch (dispute.descriptionKey) { + case DisputeDescriptionKey.initiatedByUser: + return S.of(context)!.disputeOpenedByYou(dispute.counterpartyDisplay); + case DisputeDescriptionKey.initiatedByPeer: + return S.of(context)!.disputeOpenedAgainstYou(dispute.counterpartyDisplay); + case DisputeDescriptionKey.inProgress: + // Use status text with a descriptive message + return "${S.of(context)!.disputeStatusInProgress}: ${dispute.description}"; + case DisputeDescriptionKey.resolved: + return S.of(context)!.disputeResolvedMessage; + case DisputeDescriptionKey.sellerRefunded: + // Use resolved message with additional context + return "${S.of(context)!.disputeResolvedMessage} ${S.of(context)!.seller} refunded."; + case DisputeDescriptionKey.unknown: + // Use a generic message with the status + return "${S.of(context)!.unknown} ${S.of(context)!.disputeStatusResolved}"; + } + } +} diff --git a/lib/features/disputes/widgets/disputes_list.dart b/lib/features/disputes/widgets/disputes_list.dart new file mode 100644 index 00000000..11f0f2d6 --- /dev/null +++ b/lib/features/disputes/widgets/disputes_list.dart @@ -0,0 +1,99 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; +import 'package:mostro_mobile/core/app_theme.dart'; +import 'package:mostro_mobile/features/disputes/widgets/dispute_list_item.dart'; +import 'package:mostro_mobile/data/models/dispute.dart'; +import 'package:mostro_mobile/generated/l10n.dart'; + +class DisputesList extends StatelessWidget { + const DisputesList({super.key}); + + @override + Widget build(BuildContext context) { + // Only show hardcoded mock disputes in debug mode + final mockDisputes = kDebugMode ? [ + DisputeData( + disputeId: 'dispute_001', + orderId: 'order_abc123', + status: 'initiated', + descriptionKey: DisputeDescriptionKey.initiatedByUser, + counterparty: 'user_456', + isCreator: true, + createdAt: DateTime.now().subtract(const Duration(hours: 2)), + userRole: UserRole.buyer, + ), + DisputeData( + disputeId: 'dispute_002', + orderId: 'order_def456', + status: 'in-progress', + descriptionKey: DisputeDescriptionKey.inProgress, + counterparty: 'admin_789', + isCreator: false, + createdAt: DateTime.now().subtract(const Duration(days: 1)), + userRole: UserRole.seller, + ), + DisputeData( + disputeId: 'dispute_003', + orderId: 'order_ghi789', + status: 'resolved', + descriptionKey: DisputeDescriptionKey.resolved, + counterparty: 'user_123', + isCreator: null, // Unknown creator state for resolved dispute + createdAt: DateTime.now().subtract(const Duration(days: 3)), + userRole: UserRole.buyer, + ), + ] : []; + + if (mockDisputes.isEmpty) { + return Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + Icons.gavel, + size: 64, + color: AppTheme.textSecondary, + ), + const SizedBox(height: 16), + Text( + kDebugMode ? S.of(context)!.noDisputesAvailable : S.of(context)!.disputesNotAvailable, + style: TextStyle( + color: AppTheme.textSecondary, + fontSize: 16, + ), + ), + const SizedBox(height: 8), + Text( + kDebugMode + ? S.of(context)!.disputesWillAppear + : S.of(context)!.disputesComingSoon, + style: TextStyle( + color: AppTheme.textSecondary, + fontSize: 14, + ), + textAlign: TextAlign.center, + ), + ], + ), + ); + } + + return ListView.builder( + padding: const EdgeInsets.all(16), + itemCount: mockDisputes.length, + itemBuilder: (context, index) { + final disputeData = mockDisputes[index]; + + return DisputeListItem( + dispute: disputeData, + onTap: () { + context.push('/dispute_details/${disputeData.disputeId}'); + }, + ); + }, + ); + } +} + +// DisputeData view model moved to lib/data/models/dispute.dart diff --git a/lib/features/order/screens/take_order_screen.dart b/lib/features/order/screens/take_order_screen.dart index d8d9d66f..8ae04279 100644 --- a/lib/features/order/screens/take_order_screen.dart +++ b/lib/features/order/screens/take_order_screen.dart @@ -155,7 +155,7 @@ class _TakeOrderScreenState extends ConsumerState { Flexible( child: RichText( text: TextSpan( - text: S.of(context)!.forAmount(amountString, order.currency ?? ''), + text: S.of(context)!.forAmountWithCurrency(amountString, order.currency ?? ''), style: const TextStyle( color: Colors.white70, fontSize: 16, diff --git a/lib/l10n/intl_en.arb b/lib/l10n/intl_en.arb index 87da8501..5b26f285 100644 --- a/lib/l10n/intl_en.arb +++ b/lib/l10n/intl_en.arb @@ -648,6 +648,39 @@ "yesterday": "Yesterday", "youPrefix": "You: ", "conversationsDescription": "Here you'll find your conversations with other users during trades.", + "disputesDescription": "These are your open disputes and the chats with the admin helping resolve them.", + "disputeDetails": "Dispute Details", + "disputeForOrder": "Dispute for order", + "disputeStatusInitiated": "Initiated", + "disputeStatusInProgress": "In-progress", + "disputeStatusResolved": "Resolved", + "disputeStatusClosed": "Closed", + "disputeResolvedTitle": "Dispute Resolved", + "disputeResolvedMessage": "This dispute has been resolved. The solver made a decision based on the evidence presented. Check your wallet for any refunds or payments.", + "disputeInProgress": "This dispute is currently in progress. A solver is reviewing your case.", + "disputeSellerRefunded": "This dispute has been resolved with the seller being refunded.", + "disputeUnknownStatus": "The status of this dispute is unknown.", + "disputeOpenedByYou": "You opened this dispute against the buyer {counterparty}, please read carefully below:", + "@disputeOpenedByYou": { + "placeholders": { + "counterparty": { "type": "String" } + } + }, + "disputeOpenedAgainstYou": "This dispute was opened against you by {counterparty}, please read carefully below:", + "@disputeOpenedAgainstYou": { + "placeholders": { + "counterparty": { "type": "String" } + } + }, + "disputeInstruction1": "Wait for a solver to take your dispute. Once they arrive, share any relevant evidence to help clarify the situation.", + "disputeInstruction2": "The final decision will be made based on the evidence presented.", + "disputeInstruction3": "If you don't respond, the system will assume you don't want to cooperate and you might lose the dispute.", + "disputeInstruction4": "If you want to share your chat history with {counterparty}, you can give the solver the shared key found in User Info in your conversation with that user.", + "@disputeInstruction4": { + "placeholders": { + "counterparty": { "type": "String" } + } + }, "orderId": "Order ID:", "paymentMethod": "Payment Method:", "createdOn": "Created on:", @@ -667,8 +700,8 @@ } } }, - "forAmount": "for {amount} {currency}", - "@forAmount": { + "forAmountWithCurrency": "for {amount} {currency}", + "@forAmountWithCurrency": { "placeholders": { "amount": { "type": "String" @@ -797,6 +830,61 @@ "relayErrorNoHttp": "HTTP URLs are not supported. Use websocket URLs (wss://)", "relayErrorInvalidDomain": "Invalid domain format. Use format like: relay.example.com", "relayErrorAlreadyExists": "This relay is already in your list", + + "@_comment_dispute_communication": "Dispute Communication Text", + "disputeCommunication": "Communication", + "waitingAdminAssignment": "Waiting for admin assignment", + "waitingAdminDescription": "Your dispute has been submitted. An admin will be assigned to help resolve this issue.", + "adminAssignmentDescription": "An admin will be assigned to your dispute soon. Once assigned, you can communicate directly with them here.", + "adminAssigned": "Admin assigned", + "adminAssignedDescription": "You can now communicate with the admin. Start the conversation by sending a message below.", + "admin": "Admin", + "you": "You", + "errorLoadingChat": "Error loading chat", + "adminPubkey": "Admin pubkey", + "tokenVerified": "Token verified", + "awaitingTokenVerification": "Awaiting token verification", + "askAdminQuoteToken": "Ask admin to quote this token:", + + "@_comment_dispute_input": "Dispute Input Text", + "failedSendMessage": "Failed to send message: {error}", + "@failedSendMessage": { + "placeholders": { + "error": { "type": "String" } + } + }, + "waitingAdminAssignmentInput": "Waiting for admin assignment...", + "typeYourMessage": "Type your message...", + + "@_comment_dispute_list": "Dispute List Text", + "disputesWillAppear": "Disputes will appear here when you open them from your trades", + "failedLoadDisputes": "Failed to load disputes", + "disputesNotAvailable": "Disputes not available", + "disputesComingSoon": "This feature is coming soon", + "retry": "Retry", + + "@_comment_dispute_info": "Dispute Info Text", + "unknown": "Unknown", + "disputeWith": "Dispute with {role}: {counterparty}", + "@disputeWith": { + "placeholders": { + "role": { "type": "String" }, + "counterparty": { "type": "String" } + } + }, + "orderIdLabel": "Order ID", + "disputeIdLabel": "Dispute ID", + "seller": "Seller", + "buyer": "Buyer", + + "@_comment_dispute_screen": "Dispute Screen Text", + "disputeNotFound": "Dispute not found", + "errorLoadingDispute": "Error loading dispute: {error}", + "@errorLoadingDispute": { + "placeholders": { + "error": { "type": "String" } + } + }, "relayErrorConnectionTimeout": "Relay unreachable - connection timeout", "relayTestingMessage": "Testing relay...", "relayAddedSuccessfully": "Relay added successfully: {url}", diff --git a/lib/l10n/intl_es.arb b/lib/l10n/intl_es.arb index 7faa48b4..55626c70 100644 --- a/lib/l10n/intl_es.arb +++ b/lib/l10n/intl_es.arb @@ -580,36 +580,11 @@ "someoneIsSellingTitle": "Alguien está Vendiendo Sats", "someoneIsBuyingTitle": "Alguien está Comprando Sats", "someoneIsSellingFixedTitle": "Alguien está Vendiendo {sats} Sats", - "@someoneIsSellingFixedTitle": { - "placeholders": { - "sats": {} - } - }, "someoneIsBuyingFixedTitle": "Alguien está Comprando {sats} Sats", - "@someoneIsBuyingFixedTitle": { - "placeholders": { - "sats": {} - } - }, "youCreatedOfferMessage": "Creaste esta oferta. A continuación se muestran los detalles de tu oferta. Espera a que otro usuario la tome. Se publicará durante 24 horas. Puedes cancelarla en cualquier momento usando el botón 'Cancelar'.", "youAreSellingTitle": "Estás vendiendo {sats} sats", - "@youAreSellingTitle": { - "placeholders": { - "sats": {} - } - }, "youAreBuyingTitle": "Estás comprando {sats} sats", - "@youAreBuyingTitle": { - "placeholders": { - "sats": {} - } - }, "forAmount": "por {amount}", - "@forAmount": { - "placeholders": { - "amount": {} - } - }, "timeLeftLabel": "Tiempo restante: {time}", "@timeLeftLabel": { "placeholders": { @@ -703,6 +678,39 @@ "yesterday": "Ayer", "youPrefix": "Tú: ", "conversationsDescription": "Aquí encontrarás tus conversaciones con otros usuarios durante los intercambios.", + "disputesDescription": "Estas son tus disputas abiertas y los chats con el administrador que ayuda a resolverlas.", + "disputeDetails": "Detalles de la disputa", + "disputeForOrder": "Disputa de la orden", + "disputeStatusInitiated": "Iniciada", + "disputeStatusInProgress": "En progreso", + "disputeStatusResolved": "Resuelta", + "disputeStatusClosed": "Cerrada", + "disputeResolvedTitle": "Disputa resuelta", + "disputeResolvedMessage": "Esta disputa ha sido resuelta. El mediador tomó una decisión basada en la evidencia presentada. Revisa tu billetera para ver reembolsos o pagos.", + "disputeInProgress": "Esta disputa está actualmente en progreso. Un mediador está revisando tu caso.", + "disputeSellerRefunded": "Esta disputa ha sido resuelta con el vendedor siendo reembolsado.", + "disputeUnknownStatus": "El estado de esta disputa es desconocido.", + "disputeOpenedByYou": "Abriste esta disputa contra el comprador {counterparty}, lee atentamente a continuación:", + "@disputeOpenedByYou": { + "placeholders": { + "counterparty": { "type": "String" } + } + }, + "disputeOpenedAgainstYou": "Esta disputa fue abierta contra ti por {counterparty}, lee atentamente a continuación:", + "@disputeOpenedAgainstYou": { + "placeholders": { + "counterparty": { "type": "String" } + } + }, + "disputeInstruction1": "Espera a que un mediador tome tu disputa. Una vez que llegue, comparte cualquier evidencia relevante para ayudar a aclarar la situación.", + "disputeInstruction2": "La decisión final se tomará en base a la evidencia presentada.", + "disputeInstruction3": "Si no respondes, el sistema asumirá que no deseas cooperar y podrías perder la disputa.", + "disputeInstruction4": "Si quieres compartir tu historial de chat con {counterparty}, puedes darle al mediador la clave compartida que se encuentra en Información del usuario en tu conversación con ese usuario.", + "@disputeInstruction4": { + "placeholders": { + "counterparty": { "type": "String" } + } + }, "orderId": "ID de Orden:", "paymentMethod": "Método de Pago:", "createdOn": "Creado el:", @@ -722,8 +730,8 @@ } } }, - "forAmount": "por {amount} {currency}", - "@forAmount": { + "forAmountWithCurrency": "por {amount} {currency}", + "@forAmountWithCurrency": { "placeholders": { "amount": { "type": "String" @@ -986,6 +994,62 @@ "@_comment_notification_units": "Notification time units", "notificationSeconds": "segundos", + + "@_comment_dispute_communication": "Texto de Comunicación de Disputas", + "disputeCommunication": "Comunicación", + "waitingAdminAssignment": "Esperando asignación de administrador", + "waitingAdminDescription": "Tu disputa ha sido enviada. Se asignará un administrador para ayudar a resolver este problema.", + "adminAssignmentDescription": "Se asignará un administrador a tu disputa pronto. Una vez asignado, podrás comunicarte directamente con él aquí.", + "adminAssigned": "Administrador asignado", + "adminAssignedDescription": "Ahora puedes comunicarte con el administrador. Inicia la conversación enviando un mensaje a continuación.", + "admin": "Administrador", + "you": "Tú", + "errorLoadingChat": "Error cargando el chat", + "adminPubkey": "Pubkey del administrador", + "tokenVerified": "Token verificado", + "awaitingTokenVerification": "Esperando verificación del token", + "askAdminQuoteToken": "Pide al administrador que cite este token:", + + "@_comment_dispute_input": "Texto de Entrada de Disputas", + "failedSendMessage": "Error al enviar mensaje: {error}", + "@failedSendMessage": { + "placeholders": { + "error": { "type": "String" } + } + }, + "waitingAdminAssignmentInput": "Esperando asignación de administrador...", + "typeYourMessage": "Escribe tu mensaje...", + + "@_comment_dispute_list": "Texto de Lista de Disputas", + "disputesWillAppear": "Las disputas aparecerán aquí cuando las abras desde tus intercambios", + "failedLoadDisputes": "Error al cargar disputas", + "disputesNotAvailable": "Disputas no disponibles", + "disputesComingSoon": "Esta función estará disponible pronto", + "retry": "Reintentar", + + "@_comment_dispute_info": "Texto de Información de Disputas", + "unknown": "Desconocido", + "disputeWith": "Disputa con {role}: {counterparty}", + "@disputeWith": { + "placeholders": { + "role": { "type": "String" }, + "counterparty": { "type": "String" } + } + }, + "orderIdLabel": "ID de Orden", + "disputeIdLabel": "ID de Disputa", + "seller": "Vendedor", + "buyer": "Comprador", + + "@_comment_dispute_screen": "Texto de Pantalla de Disputas", + "disputeNotFound": "Disputa no encontrada", + "errorLoadingDispute": "Error cargando disputa: {error}", + "@errorLoadingDispute": { + "placeholders": { + "error": { "type": "String" } + } + }, + "@_comment_relay_status": "Mensajes de estado de relays", "inUse": "En Uso", "notInUse": "Sin Uso", diff --git a/lib/l10n/intl_it.arb b/lib/l10n/intl_it.arb index d7ad9e04..665b2c8c 100644 --- a/lib/l10n/intl_it.arb +++ b/lib/l10n/intl_it.arb @@ -621,7 +621,7 @@ "@forAmount": { "placeholders": { "amount": { - "type": "String", + "type": "Object", "description": "Fiat amount for the transaction" } } @@ -708,6 +708,39 @@ "yesterday": "Ieri", "youPrefix": "Tu: ", "conversationsDescription": "Qui troverai le tue conversazioni con altri utenti durante gli scambi.", + "disputesDescription": "Queste sono le tue dispute aperte e le chat con l'amministratore che aiuta a risolverle.", + "disputeDetails": "Dettagli della disputa", + "disputeForOrder": "Disputa per l'ordine", + "disputeStatusInitiated": "Avviata", + "disputeStatusInProgress": "In corso", + "disputeStatusResolved": "Risolta", + "disputeStatusClosed": "Chiusa", + "disputeResolvedTitle": "Disputa risolta", + "disputeResolvedMessage": "Questa disputa è stata risolta. Il risolutore ha preso una decisione sulla base delle prove presentate. Controlla il tuo wallet per eventuali rimborsi o pagamenti.", + "disputeInProgress": "Questa disputa è attualmente in corso. Un risolutore sta esaminando il tuo caso.", + "disputeSellerRefunded": "Questa disputa è stata risolta con il venditore che è stato rimborsato.", + "disputeUnknownStatus": "Lo stato di questa disputa è sconosciuto.", + "disputeOpenedByYou": "Hai aperto questa disputa contro l'acquirente {counterparty}, leggi attentamente di seguito:", + "@disputeOpenedByYou": { + "placeholders": { + "counterparty": { "type": "String" } + } + }, + "disputeOpenedAgainstYou": "Questa disputa è stata aperta contro di te da {counterparty}, leggi attentamente di seguito:", + "@disputeOpenedAgainstYou": { + "placeholders": { + "counterparty": { "type": "String" } + } + }, + "disputeInstruction1": "Attendi che un risolutore prenda in carico la tua disputa. Una volta arrivato, condividi qualsiasi prova rilevante per aiutare a chiarire la situazione.", + "disputeInstruction2": "La decisione finale sarà presa sulla base delle prove presentate.", + "disputeInstruction3": "Se non rispondi, il sistema presumerà che tu non voglia collaborare e potresti perdere la disputa.", + "disputeInstruction4": "Se desideri condividere la cronologia della chat con {counterparty}, puoi dare al risolutore la chiave condivisa che si trova in Informazioni utente nella tua conversazione con quell'utente.", + "@disputeInstruction4": { + "placeholders": { + "counterparty": { "type": "String" } + } + }, "orderId": "ID Ordine:", "paymentMethod": "Metodo di Pagamento:", "sellingSats": "Vendendo {amount} sats", @@ -726,6 +759,7 @@ } } }, + "forAmountWithCurrency": "per {amount} {currency}", "@forAmountWithCurrency": { "placeholders": { @@ -992,6 +1026,60 @@ "@_comment_notification_units": "Notification time units", "notificationSeconds": "secondi", + + "@_comment_dispute_communication": "Testo Comunicazione Controversie", + "disputeCommunication": "Comunicazione", + "waitingAdminAssignment": "In attesa dell'assegnazione dell'amministratore", + "waitingAdminDescription": "La tua controversia è stata inviata. Un amministratore verrà assegnato per aiutare a risolvere questo problema.", + "adminAssignmentDescription": "Un amministratore verrà assegnato alla tua controversia a breve. Una volta assegnato, potrai comunicare direttamente con lui qui.", + "adminAssigned": "Amministratore assegnato", + "adminAssignedDescription": "Ora puoi comunicare con l'amministratore. Inizia la conversazione inviando un messaggio qui sotto.", + "admin": "Amministratore", + "you": "Tu", + "errorLoadingChat": "Errore nel caricamento della chat", + "adminPubkey": "Pubkey dell'amministratore", + "tokenVerified": "Token verificato", + "awaitingTokenVerification": "In attesa della verifica del token", + "askAdminQuoteToken": "Chiedi all'amministratore di citare questo token:", + + "@_comment_dispute_input": "Testo Input Controversie", + "failedSendMessage": "Invio messaggio fallito: {error}", + "@failedSendMessage": { + "placeholders": { + "error": { "type": "String" } + } + }, + "waitingAdminAssignmentInput": "In attesa dell'assegnazione dell'amministratore...", + "typeYourMessage": "Scrivi il tuo messaggio...", + + "@_comment_dispute_list": "Testo Lista Controversie", + "disputesWillAppear": "Le controversie appariranno qui quando le apri dai tuoi scambi", + "failedLoadDisputes": "Impossibile caricare le controversie", + "disputesNotAvailable": "Controversie non disponibili", + "disputesComingSoon": "Questa funzione sarà disponibile presto", + + "@_comment_dispute_info": "Testo Informazioni Controversie", + "unknown": "Sconosciuto", + "disputeWith": "Controversia con {role}: {counterparty}", + "@disputeWith": { + "placeholders": { + "role": { "type": "String" }, + "counterparty": { "type": "String" } + } + }, + "disputeIdLabel": "ID Controversia", + "seller": "Venditore", + "buyer": "Compratore", + + "@_comment_dispute_screen": "Testo Schermata Controversie", + "disputeNotFound": "Controversia non trovata", + "errorLoadingDispute": "Errore nel caricamento della controversia: {error}", + "@errorLoadingDispute": { + "placeholders": { + "error": { "type": "String" } + } + }, + "@_comment_relay_status": "Messaggi di stato relay", "inUse": "In Uso", "notInUse": "Non In Uso", diff --git a/lib/services/dispute_service.dart b/lib/services/dispute_service.dart new file mode 100644 index 00000000..f4eb7e20 --- /dev/null +++ b/lib/services/dispute_service.dart @@ -0,0 +1,28 @@ +import 'package:mostro_mobile/data/models/dispute.dart'; +import 'package:mostro_mobile/data/repositories/dispute_repository.dart'; + +/// Stub service for disputes - UI only implementation +class DisputeService { + static final DisputeService _instance = DisputeService._internal(); + factory DisputeService() => _instance; + DisputeService._internal(); + + final DisputeRepository _disputeRepository = DisputeRepository(); + + Future> getUserDisputes() async { + return await _disputeRepository.getUserDisputes(); + } + + Future getDispute(String disputeId) async { + return await _disputeRepository.getDispute(disputeId); + } + + Future sendDisputeMessage(String disputeId, String message) async { + await _disputeRepository.sendDisputeMessage(disputeId, message); + } + + Future initiateDispute(String orderId, String reason) async { + // Mock implementation + await Future.delayed(const Duration(milliseconds: 500)); + } +} \ No newline at end of file diff --git a/lib/shared/widgets/order_cards.dart b/lib/shared/widgets/order_cards.dart index a79054bd..c0789682 100644 --- a/lib/shared/widgets/order_cards.dart +++ b/lib/shared/widgets/order_cards.dart @@ -55,7 +55,7 @@ class OrderAmountCard extends ConsumerWidget { Flexible( child: RichText( text: TextSpan( - text: S.of(context)!.forAmount(amountString, currency), + text: S.of(context)!.forAmountWithCurrency(amountString, currency), style: const TextStyle( color: Colors.white70, fontSize: 16, diff --git a/pubspec.lock b/pubspec.lock index 7b9f004e..428fbca1 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -840,26 +840,26 @@ packages: dependency: transitive description: name: leak_tracker - sha256: "6bb818ecbdffe216e81182c2f0714a2e62b593f4a4f13098713ff1685dfb6ab0" + sha256: "8dcda04c3fc16c14f48a7bb586d4be1f0d1572731b6d81d51772ef47c02081e0" url: "https://pub.dev" source: hosted - version: "10.0.9" + version: "11.0.1" leak_tracker_flutter_testing: dependency: transitive description: name: leak_tracker_flutter_testing - sha256: f8b613e7e6a13ec79cfdc0e97638fddb3ab848452eff057653abd3edba760573 + sha256: "1dbc140bb5a23c75ea9c4811222756104fbcd1a27173f0c34ca01e16bea473c1" url: "https://pub.dev" source: hosted - version: "3.0.9" + version: "3.0.10" leak_tracker_testing: dependency: transitive description: name: leak_tracker_testing - sha256: "6ba465d5d76e67ddf503e1161d1f4a6bc42306f9d66ca1e8f079a47290fb06d3" + sha256: "8d5a2d49f4a66b49744b23b018848400d23e54caf9463f4eb20df3eb8acb2eb1" url: "https://pub.dev" source: hosted - version: "3.0.1" + version: "3.0.2" lints: dependency: transitive description: @@ -1462,26 +1462,26 @@ packages: dependency: "direct dev" description: name: test - sha256: "301b213cd241ca982e9ba50266bd3f5bd1ea33f1455554c5abb85d1be0e2d87e" + sha256: "65e29d831719be0591f7b3b1a32a3cda258ec98c58c7b25f7b84241bc31215bb" url: "https://pub.dev" source: hosted - version: "1.25.15" + version: "1.26.2" test_api: dependency: transitive description: name: test_api - sha256: fb31f383e2ee25fbbfe06b40fe21e1e458d14080e3c67e7ba0acfde4df4e0bbd + sha256: "522f00f556e73044315fa4585ec3270f1808a4b186c936e612cab0b565ff1e00" url: "https://pub.dev" source: hosted - version: "0.7.4" + version: "0.7.6" test_core: dependency: transitive description: name: test_core - sha256: "84d17c3486c8dfdbe5e12a50c8ae176d15e2a771b96909a9442b40173649ccaa" + sha256: "80bf5a02b60af04b09e14f6fe68b921aad119493e26e490deaca5993fef1b05a" url: "https://pub.dev" source: hosted - version: "0.6.8" + version: "0.6.11" timeago: dependency: "direct main" description: @@ -1614,10 +1614,10 @@ packages: dependency: transitive description: name: vector_math - sha256: "80b3257d1492ce4d091729e3a67a60407d227c27241d6927be0130c98e741803" + sha256: d530bd74fea330e6e364cda7a85019c434070188383e1cd8d9777ee586914c5b url: "https://pub.dev" source: hosted - version: "2.1.4" + version: "2.2.0" vm_service: dependency: transitive description: