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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion lib/core/app_routes.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -102,7 +103,7 @@ final GoRouter appRouter = GoRouter(
),
GoRoute(
path: AppRoute.orderBook,
builder: (_, __) => const _Stub('Order Book'),
builder: (_, __) => const TradesScreen(),
),
GoRoute(
path: AppRoute.addOrder,
Expand Down
105 changes: 105 additions & 0 deletions lib/features/trades/providers/trades_providers.dart
Original file line number Diff line number Diff line change
@@ -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>((_) => 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<List<TradeListItem>>((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 = <TradeListItem>[];

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<int>((_) => 0);
195 changes: 195 additions & 0 deletions lib/features/trades/screens/trades_screen.dart
Original file line number Diff line number Diff line change
@@ -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<AppColors>();
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<TradeStatusFilter?> onChanged;

@override
Widget build(BuildContext context) {
return PopupMenuButton<TradeStatusFilter>(
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,
),
],
),
);
}
}
Loading