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
10 changes: 10 additions & 0 deletions lib/features/home/providers/home_order_providers.dart
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:mostro/src/rust/api/orders.dart' as orders_api;
import 'package:mostro/src/rust/api/types.dart';

export 'package:mostro/src/rust/api/types.dart' show OrderStatus;

// ── Order type ────────────────────────────────────────────────────────────────

enum OrderType { buy, sell }
Expand Down Expand Up @@ -60,6 +62,8 @@ class OrderItem {
this.rating = 0.0,
this.tradeCount = 0,
this.daysActive = 0,
this.status = OrderStatus.pending,
this.amountSats,
}) {
final isFixed = fiatAmount != null &&
fiatAmountMin == null &&
Expand Down Expand Up @@ -89,6 +93,10 @@ class OrderItem {
final double rating;
final int tradeCount;
final int daysActive;
/// Current order status from the Mostro protocol.
final OrderStatus status;
/// Sats amount resolved by Mostro (non-null once Mostro accepts the take).
final BigInt? amountSats;

bool get isRange => fiatAmountMin != null && fiatAmountMax != null;

Expand Down Expand Up @@ -118,6 +126,8 @@ class OrderItem {
expiresAt: info.expiresAt != null
? DateTime.fromMillisecondsSinceEpoch(info.expiresAt! * 1000)
: null,
status: info.status,
amountSats: info.amountSats,
);
}

Expand Down
38 changes: 38 additions & 0 deletions lib/features/order/providers/trade_state_provider.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:mostro/src/rust/api/orders.dart' as orders_api;
import 'package:mostro/src/rust/api/types.dart';

/// Maps `orderId` → whether the local user is the buyer in that trade.
///
/// Set this before navigating to [AddLightningInvoiceScreen] or
/// [TradeDetailScreen] so those screens know the user's role.
final tradeRoleProvider =
StateProvider<Map<String, bool>>((ref) => const {});
Comment on lines +9 to +10

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

tradeRoleProvider is too ephemeral for a trade-critical role.

This map is cleared on every app restart, but downstream screens now use it to choose buyer vs seller actions. Reopened trades therefore cannot recover the local role reliably unless some other source repopulates it. Persist the role with the trade record, or derive it from backend state instead of keeping it only in Riverpod memory.

As per coding guidelines, "Use Sembast for UI-layer state management in Dart across all platforms".

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@lib/features/order/providers/trade_state_provider.dart` around lines 9 - 10,
tradeRoleProvider currently holds a volatile Map in Riverpod
(StateProvider<Map<String,bool>>) which is cleared on app restart; persist the
role instead of keeping it only in memory by storing the buyer/seller flag on
the trade record or deriving it from backend/state source and loading it into
the UI layer via Sembast on startup; update usage to read from the trade entity
(or an initialized Sembast-backed store) rather than relying on
tradeRoleProvider alone, and migrate any callers of tradeRoleProvider to fetch
the persisted field (or call the backend) during trade load/rehydration so
reopened trades recover the correct role.


/// Poll `getOrder()` every 2 s until `amountSats` is non-null, then stop.
///
/// Returns `null` while waiting. Useful for the add-invoice screen which
/// needs the sats amount before it can submit a Lightning invoice.
final tradeAmountProvider =
StreamProvider.family.autoDispose<BigInt?, String>((ref, orderId) async* {
while (true) {
final info = await orders_api.getOrder(orderId: orderId);
final sats = info?.amountSats;
yield sats;
if (sats != null) return; // done — no need to keep polling
await Future.delayed(const Duration(seconds: 2));
}
});

/// Live order status for a single trade, polled from the order book every 2 s.
///
/// Returns [OrderStatus.pending] as the initial / fallback value while loading.
final tradeStatusProvider =
StreamProvider.family.autoDispose<OrderStatus, String>((ref, orderId) async* {
yield OrderStatus.pending; // immediate first emission so UI doesn't hang
while (true) {
await Future.delayed(const Duration(seconds: 2));
final info = await orders_api.getOrder(orderId: orderId);
if (info != null) yield info.status;
}
});
62 changes: 47 additions & 15 deletions lib/features/order/screens/add_lightning_invoice_screen.dart
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ 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/order/providers/trade_state_provider.dart';
import 'package:mostro/features/settings/providers/nwc_provider.dart';
import 'package:mostro/shared/widgets/nwc_invoice_widget.dart';
import 'package:mostro/src/rust/api/orders.dart' as orders_api;
Expand Down Expand Up @@ -41,20 +42,44 @@ class _AddLightningInvoiceScreenState
super.dispose();
}

bool get _isValid =>
_invoiceController.text.trim().isNotEmpty &&
widget.amountSats != null &&
widget.amountSats! > 0;
BigInt? _resolvedSats(WidgetRef ref) {
final fromProvider = ref.watch(tradeAmountProvider(widget.orderId)).valueOrNull;
if (fromProvider != null) return fromProvider;
final fallback = widget.amountSats;
return fallback != null ? BigInt.from(fallback) : null;
}

bool _isLnAddress(String text) => text.contains('@');

Future<void> _submit() async {
if (_submitting || !_isValid) return;
bool _isValid(WidgetRef ref) {
final text = _invoiceController.text.trim();
if (text.isEmpty) return false;
// Lightning Address requires a known sats amount before submission.
if (_isLnAddress(text) && _resolvedSats(ref) == null) return false;
return true;
}

Future<void> _submit(WidgetRef ref) async {
if (_submitting) return;
final input = _invoiceController.text.trim();
// For Lightning Addresses, the sats amount must be resolved before sending —
// the Rust side uses it to resolve the address. Bolt11 invoices encode
// their own amount so BigInt.one is an acceptable non-zero placeholder.
final resolvedSats = _resolvedSats(ref);
if (_isLnAddress(input) && resolvedSats == null) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Waiting for trade amount — please try again shortly.')),
);
return;
}
final sats = resolvedSats ?? BigInt.one;
setState(() => _submitting = true);

try {
await orders_api.sendInvoice(
orderId: widget.orderId,
invoiceOrAddress: _invoiceController.text.trim(),
amountSats: BigInt.from(widget.amountSats!),
amountSats: sats,
);

if (!mounted) return;
Expand All @@ -79,10 +104,12 @@ class _AddLightningInvoiceScreenState

final isWalletConnected = ref.watch(isWalletConnectedProvider);

// Amount not yet resolved and user hasn't explicitly chosen manual mode:
// show a loading indicator while waiting for the trade provider.
final sats = widget.amountSats;
if (sats == null && !_manualMode) {
// Resolve sats: provider first (live polling), fall back to constructor param.
final sats = _resolvedSats(ref);

// When NWC is connected, we need the sats amount to auto-generate an invoice.
// Show a loading indicator only in that case. Manual entry is always available.
if (isWalletConnected && sats == null && !_manualMode) {
return Scaffold(
appBar: AppBar(title: const Text('Add Invoice')),
body: Center(
Expand All @@ -95,6 +122,11 @@ class _AddLightningInvoiceScreenState
'Fetching trade amount…',
style: TextStyle(color: Theme.of(context).extension<AppColors>()?.textSecondary),
),
const SizedBox(height: AppSpacing.md),
TextButton(
onPressed: () => setState(() => _manualMode = true),
child: const Text('Enter invoice manually'),
),
],
),
),
Expand All @@ -103,17 +135,17 @@ class _AddLightningInvoiceScreenState

// If NWC wallet is connected, amount is known, and we haven't fallen back
// to manual, show the auto-invoice widget instead of the manual form.
if (isWalletConnected && !_manualMode && sats != null && sats > 0) {
if (isWalletConnected && !_manualMode && sats != null && sats > BigInt.zero) {
return Scaffold(
appBar: AppBar(title: const Text('Add Invoice')),
body: Padding(
padding: const EdgeInsets.all(AppSpacing.lg),
child: Center(
child: NwcInvoiceWidget(
amountSats: sats,
amountSats: sats.toInt(),
onInvoiceConfirmed: (invoice) {
_invoiceController.text = invoice;
_submit();
_submit(ref);
},
onFallbackToManual: () => setState(() => _manualMode = true),
),
Expand Down Expand Up @@ -196,7 +228,7 @@ class _AddLightningInvoiceScreenState
const SizedBox(width: AppSpacing.md),
Expanded(
child: FilledButton(
onPressed: _isValid ? _submit : null,
onPressed: _isValid(ref) ? () => _submit(ref) : null,
style: FilledButton.styleFrom(
backgroundColor: green,
foregroundColor: Colors.black,
Expand Down
31 changes: 25 additions & 6 deletions lib/features/order/screens/take_order_screen.dart
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,12 @@ import 'package:mostro/core/app_routes.dart';
import 'package:mostro/core/app_theme.dart';
import 'package:mostro/features/account/providers/privacy_mode_provider.dart';
import 'package:mostro/features/home/providers/home_order_providers.dart';
import 'package:mostro/features/order/providers/trade_state_provider.dart';
import 'package:mostro/features/order/widgets/range_amount_modal.dart';
import 'package:mostro/shared/utils/fiat_currencies.dart';
import 'package:mostro/src/rust/api/orders.dart' as orders_api;
import 'package:mostro/src/rust/api/settings.dart' as settings_api;
import 'package:mostro/src/rust/api/types.dart';

/// Take order screen — displays order details and allows the user
/// to take (buy or sell) the order.
Expand All @@ -37,7 +41,6 @@ class _TakeOrderScreenState extends ConsumerState<TakeOrderScreen> {
Timer? _countdownTimer;
Duration _remaining = Duration.zero;
bool _submitting = false;
// ignore: unused_field — used when Rust bridge take_order() is wired (Phase 8+).
double? _selectedAmount;

@override
Expand Down Expand Up @@ -110,15 +113,31 @@ class _TakeOrderScreenState extends ConsumerState<TakeOrderScreen> {
setState(() => _submitting = true);

try {
// TODO (Phase 8+): Call take_order(orderId, _selectedAmount) via Rust bridge.
await Future.delayed(const Duration(milliseconds: 500));
// Dispatch take-order to Mostro via the Rust bridge.
await orders_api.takeOrder(
orderId: widget.orderId,
role: widget.isBuying ? TradeRole.buyer : TradeRole.seller,
fiatAmount: _selectedAmount,
);

if (!mounted) return;

// Navigate based on role:
// Buyer → add invoice screen; Seller → pay invoice screen.
// Record the user's role so TradeDetailScreen can read it.
ref.read(tradeRoleProvider.notifier).update(
(map) => {...map, widget.orderId: widget.isBuying},
);

if (widget.isBuying) {
context.push(AppRoute.addInvoicePath(widget.orderId));
// Check whether a default LN address is configured. If yes, Mostro
// will pay it directly and the buyer can skip the add-invoice step.
final settings = await settings_api.getSettings();
if (!mounted) return;
if (settings.defaultLightningAddress != null) {
// LN address was included in take-sell payload — go straight to trade.
context.go(AppRoute.tradeDetailPath(widget.orderId));
} else {
context.push(AppRoute.addInvoicePath(widget.orderId));
}
} else {
context.push(AppRoute.payInvoicePath(widget.orderId));
}
Expand Down
Loading