diff --git a/lib/core/app_routes.dart b/lib/core/app_routes.dart index 93acf94f..a025d3eb 100644 --- a/lib/core/app_routes.dart +++ b/lib/core/app_routes.dart @@ -12,6 +12,7 @@ import 'package:mostro/features/order/screens/take_order_screen.dart'; import 'package:mostro/features/chat/screens/chat_room_screen.dart'; import 'package:mostro/features/chat/screens/chat_rooms_screen.dart'; import 'package:mostro/features/trades/screens/trade_detail_screen.dart'; +import 'package:mostro/features/trades/screens/trades_screen.dart'; import 'package:mostro/features/walkthrough/providers/first_run_provider.dart'; import 'package:mostro/features/walkthrough/screens/walkthrough_screen.dart'; @@ -102,7 +103,7 @@ final GoRouter appRouter = GoRouter( ), GoRoute( path: AppRoute.orderBook, - builder: (_, __) => const _Stub('Order Book'), + builder: (_, __) => const TradesScreen(), ), GoRoute( path: AppRoute.addOrder, diff --git a/lib/features/trades/providers/trades_providers.dart b/lib/features/trades/providers/trades_providers.dart new file mode 100644 index 00000000..0eef2621 --- /dev/null +++ b/lib/features/trades/providers/trades_providers.dart @@ -0,0 +1,105 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +// ── TradeStatusFilter ───────────────────────────────────────────────────────── + +/// Possible values for the My Trades status filter dropdown. +enum TradeStatusFilter { + all('All'), + pending('Pending'), + active('Active'), + fiatSent('Fiat Sent'), + success('Success'), + canceled('Canceled'), + dispute('Dispute'); + + const TradeStatusFilter(this.label); + final String label; +} + +// ── TradeRole ───────────────────────────────────────────────────────────────── + +/// Whether the local user created this trade or took it. +enum TradeRole { creator, taker } + +// ── TradeListItem model ─────────────────────────────────────────────────────── + +/// Immutable UI-layer model for one trade row in the My Trades list. +/// +/// Populated from the Rust bridge trade session once FFI bindings are wired. +/// For now the list is empty; mock data can be added in tests. +@immutable +class TradeListItem { + const TradeListItem({ + required this.orderId, + required this.isSelling, + required this.status, + required this.role, + required this.fiatAmount, + required this.fiatCurrency, + required this.paymentMethod, + required this.createdAt, + }); + + /// Unique trade identifier (Nostr event ID hex). + final String orderId; + + /// true → "Selling Bitcoin"; false → "Buying Bitcoin". + final bool isSelling; + + /// Current trade status. + final TradeStatusFilter status; + + /// Whether the local user is the trade creator or taker. + final TradeRole role; + + /// Human-readable fiat amount (e.g. "966"). + final String fiatAmount; + + /// ISO 4217 fiat currency code (e.g. "ARS"). + final String fiatCurrency; + + /// Payment method string (e.g. "Mercado Pago"). + final String paymentMethod; + + /// Unix timestamp (seconds) when this trade was created. + final int createdAt; +} + +// ── Providers ───────────────────────────────────────────────────────────────── + +/// Currently selected status filter for the My Trades dropdown. +/// +/// Defaults to [TradeStatusFilter.all]. +final selectedStatusFilterProvider = + StateProvider((_) => TradeStatusFilter.all); + +/// All trades, filtered by [selectedStatusFilterProvider]. +/// +/// Returns an empty list until the Rust bridge sessions are wired (Phase 11+). +/// The list is sorted newest-first by [TradeListItem.createdAt]. +final filteredTradesWithOrderStateProvider = + Provider>((ref) { + final filter = ref.watch(selectedStatusFilterProvider); + + // TODO(bridge): Watch all active sessions via the Rust bridge and map each + // SessionState to a TradeListItem. For now returns an empty list so the + // empty-state UI is exercised. + const allTrades = []; + + final filtered = filter == TradeStatusFilter.all + ? allTrades + : allTrades.where((t) => t.status == filter).toList(); + + // Sort newest first. + final sorted = [...filtered] + ..sort((a, b) => b.createdAt.compareTo(a.createdAt)); + + return sorted; +}); + +/// Unseen trade update count for the My Trades tab badge. +/// +/// Wired to [tradesNotificationCountProvider] in BottomNavBar. +/// Returns 0 until bridge events are integrated. +final orderBookNotificationCountProvider = Provider((_) => 0); diff --git a/lib/features/trades/screens/trades_screen.dart b/lib/features/trades/screens/trades_screen.dart new file mode 100644 index 00000000..e39204b5 --- /dev/null +++ b/lib/features/trades/screens/trades_screen.dart @@ -0,0 +1,195 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import 'package:mostro/core/app_routes.dart'; +import 'package:mostro/core/app_theme.dart'; +import 'package:mostro/features/trades/providers/trades_providers.dart'; +import 'package:mostro/features/trades/widgets/trades_list_item.dart'; +import 'package:mostro/shared/widgets/bottom_nav_bar.dart'; +import 'package:mostro/shared/widgets/notification_bell.dart'; + +/// My Trades screen — Route [AppRoute.orderBook] (`/order_book`, bottom nav tab 1). +/// +/// Shows all user trades sorted newest-first with a status filter dropdown. +/// Tapping a card navigates to `/trade_detail/:orderId`. +class TradesScreen extends ConsumerWidget { + const TradesScreen({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final colors = Theme.of(context).extension(); + assert(colors != null, 'AppColors theme extension must be registered'); + if (colors == null) return const SizedBox.shrink(); + + final trades = ref.watch(filteredTradesWithOrderStateProvider); + final selectedFilter = ref.watch(selectedStatusFilterProvider); + + return Scaffold( + appBar: AppBar( + leading: Builder( + builder: (context) => IconButton( + icon: const Icon(Icons.menu), + onPressed: () => Scaffold.of(context).openDrawer(), + tooltip: 'Menu', + ), + ), + title: Image.asset( + 'assets/images/mostro_logo.png', + height: 28, + errorBuilder: (_, __, ___) => Text( + 'Mostro', + style: Theme.of(context).textTheme.headlineMedium, + ), + ), + centerTitle: true, + actions: const [NotificationBell()], + ), + body: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // ── Sub-header ────────────────────────────────────────────────── + Padding( + padding: const EdgeInsets.fromLTRB( + AppSpacing.lg, + AppSpacing.md, + AppSpacing.lg, + 0, + ), + child: Row( + children: [ + Expanded( + child: Text( + 'My Trades', + style: Theme.of(context).textTheme.headlineSmall?.copyWith( + color: colors.textPrimary, + fontWeight: FontWeight.bold, + ), + ), + ), + + // ── Status filter dropdown ────────────────────────────── + _StatusFilterButton( + selected: selectedFilter, + colors: colors, + onChanged: (filter) { + if (filter != null) { + ref.read(selectedStatusFilterProvider.notifier).state = + filter; + } + }, + ), + ], + ), + ), + const SizedBox(height: AppSpacing.sm), + + // ── Trade list / empty state ───────────────────────────────────── + Expanded( + child: trades.isEmpty + ? _EmptyState(colors: colors) + : ListView.builder( + padding: const EdgeInsets.only( + top: AppSpacing.xs, + bottom: AppSpacing.lg, + ), + itemCount: trades.length, + itemBuilder: (context, index) => + TradesListItem(trade: trades[index]), + ), + ), + ], + ), + bottomNavigationBar: const BottomNavBar(), + ); + } +} + +// ── Status filter button ────────────────────────────────────────────────────── + +class _StatusFilterButton extends StatelessWidget { + const _StatusFilterButton({ + required this.selected, + required this.colors, + required this.onChanged, + }); + + final TradeStatusFilter selected; + final AppColors colors; + final ValueChanged onChanged; + + @override + Widget build(BuildContext context) { + return PopupMenuButton( + initialValue: selected, + onSelected: onChanged, + itemBuilder: (_) => TradeStatusFilter.values + .map( + (f) => PopupMenuItem( + value: f, + child: Text(f.label), + ), + ) + .toList(), + child: Container( + padding: const EdgeInsets.symmetric( + horizontal: AppSpacing.sm, + vertical: AppSpacing.xs, + ), + decoration: BoxDecoration( + color: colors.backgroundInput, + borderRadius: BorderRadius.circular(AppRadius.chip), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.filter_list, size: 14, color: colors.textSecondary), + const SizedBox(width: 4), + Text( + 'Status | ${selected.label}', + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: colors.textSecondary, + ), + ), + const SizedBox(width: 2), + Icon(Icons.arrow_drop_down, size: 16, color: colors.textSecondary), + ], + ), + ), + ); + } +} + +// ── Empty state ─────────────────────────────────────────────────────────────── + +class _EmptyState extends StatelessWidget { + const _EmptyState({required this.colors}); + + final AppColors colors; + + @override + Widget build(BuildContext context) { + return Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.bolt_outlined, size: 64, color: colors.textSubtle), + const SizedBox(height: AppSpacing.lg), + Text( + 'No trades', + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: colors.textSecondary, + ), + ), + const SizedBox(height: AppSpacing.sm), + Text( + 'Your active and completed trades will appear here.', + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: colors.textSubtle, + ), + textAlign: TextAlign.center, + ), + ], + ), + ); + } +} diff --git a/lib/features/trades/widgets/trades_list_item.dart b/lib/features/trades/widgets/trades_list_item.dart new file mode 100644 index 00000000..5b2f0f1a --- /dev/null +++ b/lib/features/trades/widgets/trades_list_item.dart @@ -0,0 +1,214 @@ +import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; + +import 'package:mostro/core/app_routes.dart'; +import 'package:mostro/core/app_theme.dart'; +import 'package:mostro/features/trades/providers/trades_providers.dart'; + +/// A single card row in the My Trades list. +/// +/// Layout: +/// ┌──────────────────────────────────────────────────────── ❯ ─┐ +/// │ Selling / Buying Bitcoin │ +/// │ [Status chip] [Role chip] │ +/// │ 🏦 966 ARS · 2 hours ago Mercado Pago │ +/// └─────────────────────────────────────────────────────────────┘ +/// +/// Tap → navigates to `/trade_detail/:orderId`. +class TradesListItem extends StatelessWidget { + const TradesListItem({ + super.key, + required this.trade, + }); + + final TradeListItem trade; + + @override + Widget build(BuildContext context) { + final colors = Theme.of(context).extension(); + assert(colors != null, 'AppColors theme extension must be registered'); + if (colors == null) return const SizedBox.shrink(); + final textTheme = Theme.of(context).textTheme; + + final titleText = + trade.isSelling ? 'Selling Bitcoin' : 'Buying Bitcoin'; + + final (statusBg, statusFg) = _statusColors(trade.status); + final statusLabel = trade.status.label; + + final roleLabel = + trade.role == TradeRole.creator ? 'Created by you' : 'Taken by you'; + + final timeAgo = _timeAgo(trade.createdAt); + + return Card( + margin: const EdgeInsets.symmetric( + horizontal: AppSpacing.lg, + vertical: AppSpacing.xs, + ), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(AppRadius.card), + ), + child: InkWell( + borderRadius: BorderRadius.circular(AppRadius.card), + onTap: () => context.push(AppRoute.tradeDetailPath(trade.orderId)), + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: AppSpacing.md, + vertical: AppSpacing.md, + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + // ── Main content ─────────────────────────────────── + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Row 1: Title + Text( + titleText, + style: textTheme.bodyLarge?.copyWith( + color: colors.textPrimary, + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(height: AppSpacing.xs), + + // Row 2: Status + role chips + Wrap( + spacing: AppSpacing.xs, + children: [ + _Chip( + label: statusLabel, + background: statusBg, + foreground: statusFg, + ), + _Chip( + label: roleLabel, + background: AppColors.statusActive.$1, + foreground: AppColors.statusActive.$2, + ), + ], + ), + const SizedBox(height: AppSpacing.xs), + + // Row 3: Amount + time + payment method + Row( + children: [ + Icon( + Icons.account_balance_outlined, + size: 14, + color: colors.textSubtle, + ), + const SizedBox(width: 4), + Text( + '${trade.fiatAmount} ${trade.fiatCurrency}', + style: textTheme.bodySmall?.copyWith( + color: colors.textSecondary, + ), + ), + const SizedBox(width: AppSpacing.xs), + Text( + '· $timeAgo', + style: textTheme.bodySmall?.copyWith( + color: colors.textSubtle, + ), + ), + ], + ), + const SizedBox(height: 2), + Text( + trade.paymentMethod, + style: textTheme.bodySmall?.copyWith( + color: colors.textSubtle, + ), + ), + ], + ), + ), + + // ── Chevron ──────────────────────────────────────── + Icon( + Icons.chevron_right, + color: colors.textSubtle, + ), + ], + ), + ), + ), + ); + } + + // ── Helpers ────────────────────────────────────────────────────────────── + + /// Maps a [TradeStatusFilter] to (background, foreground) colors. + static (Color, Color) _statusColors(TradeStatusFilter status) { + return switch (status) { + TradeStatusFilter.pending => AppColors.statusPending, + TradeStatusFilter.active => AppColors.statusActive, + TradeStatusFilter.fiatSent => AppColors.statusActive, + TradeStatusFilter.success => AppColors.statusSuccess, + TradeStatusFilter.canceled => AppColors.statusInactive, + TradeStatusFilter.dispute => AppColors.statusDispute, + // `all` is a filter sentinel, not a real trade status — shouldn't occur + // in practice since TradesListItem only renders individual trades. + TradeStatusFilter.all => AppColors.statusInactive, + }; + } + + /// Returns a human-readable "time ago" string from a unix timestamp. + static String _timeAgo(int unixSeconds) { + final dt = DateTime.fromMillisecondsSinceEpoch(unixSeconds * 1000); + final diff = DateTime.now().difference(dt); + + // Guard against clock skew producing a negative (future) timestamp. + if (diff.isNegative || diff.inSeconds < 60) return 'just now'; + if (diff.inMinutes < 60) { + final m = diff.inMinutes; + return '$m ${m == 1 ? 'minute' : 'minutes'} ago'; + } + if (diff.inHours < 24) { + final h = diff.inHours; + return '$h ${h == 1 ? 'hour' : 'hours'} ago'; + } + final d = diff.inDays; + return '$d ${d == 1 ? 'day' : 'days'} ago'; + } +} + +// ── Small chip widget ───────────────────────────────────────────────────────── + +class _Chip extends StatelessWidget { + const _Chip({ + required this.label, + required this.background, + required this.foreground, + }); + + final String label; + final Color background; + final Color foreground; + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.symmetric( + horizontal: AppSpacing.sm, + vertical: 2, + ), + decoration: BoxDecoration( + color: background, + borderRadius: BorderRadius.circular(AppRadius.chip), + ), + child: Text( + label, + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: foreground, + fontWeight: FontWeight.w500, + fontSize: 11, + ), + ), + ); + } +} diff --git a/specs/004-mostro-p2p-client/tasks.md b/specs/004-mostro-p2p-client/tasks.md index f5b649dc..0984589c 100644 --- a/specs/004-mostro-p2p-client/tasks.md +++ b/specs/004-mostro-p2p-client/tasks.md @@ -261,9 +261,9 @@ configuration. **Independent Test**: Create a trade → My Trades tab shows card with correct status badge + role badge + fiat amount. Filter by "Active" → only active trades shown. Tab has red dot when trade updates occur. -- [ ] T082 Implement trades screen in `lib/features/trades/screens/trades_screen.dart`: AppBar (☰ + Mostro logo + bell). Sub-header: "My Trades" title (bold white) + "▼ Status | All" filter dropdown. Scrollable list of `TradesListItem`. Empty state "No trades" with icon. Route: `/order_book` (bottom nav tab 2). Sorted newest first. -- [ ] T083 [P] Implement trade list item widget in `lib/features/trades/widgets/trades_list_item.dart`: card with chevron (→). Top: "Selling Bitcoin"/"Buying Bitcoin" (bold white). Below: colored status badge chip (Pending=yellow, Active=blue, FiatSent=blue, Success=green, Canceled=gray, Dispute=red) + role badge ("Created by you"/"Taken by you", blue chip). Amount: bank icon + "966 ARS" + time ago (gray small). Payment method (gray small). Tap → `/trade_detail/:orderId`. -- [ ] T084 [P] Implement trades providers in `lib/features/trades/providers/trades_providers.dart`: `filteredTradesWithOrderStateProvider` (watches all sessions, reads `orderNotifierProvider(orderId)` for each, applies status filter). `selectedStatusFilterProvider` (StateProvider for dropdown). `orderBookNotificationCountProvider` (unseen trade update count for My Trades tab badge). +- [x] T082 Implement trades screen in `lib/features/trades/screens/trades_screen.dart`: AppBar (☰ + Mostro logo + bell). Sub-header: "My Trades" title (bold white) + "▼ Status | All" filter dropdown. Scrollable list of `TradesListItem`. Empty state "No trades" with icon. Route: `/order_book` (bottom nav tab 2). Sorted newest first. +- [x] T083 [P] Implement trade list item widget in `lib/features/trades/widgets/trades_list_item.dart`: card with chevron (→). Top: "Selling Bitcoin"/"Buying Bitcoin" (bold white). Below: colored status badge chip (Pending=yellow, Active=blue, FiatSent=blue, Success=green, Canceled=gray, Dispute=red) + role badge ("Created by you"/"Taken by you", blue chip). Amount: bank icon + "966 ARS" + time ago (gray small). Payment method (gray small). Tap → `/trade_detail/:orderId`. +- [x] T084 [P] Implement trades providers in `lib/features/trades/providers/trades_providers.dart`: `filteredTradesWithOrderStateProvider` (watches all sessions, reads `orderNotifierProvider(orderId)` for each, applies status filter). `selectedStatusFilterProvider` (StateProvider for dropdown). `orderBookNotificationCountProvider` (unseen trade update count for My Trades tab badge). **Checkpoint**: My Trades tab populated with correct cards, filter dropdown works, badge increments on trade update.