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
31 changes: 28 additions & 3 deletions lib/features/order/providers/trade_state_provider.dart
Original file line number Diff line number Diff line change
Expand Up @@ -27,17 +27,42 @@ final tradeAmountProvider =
/// Live order status for a single trade, polled from the order book every 2 s.
///
/// Starts with an immediate fetch (no initial delay) so the first emission
/// reflects the real relay status. While loading, callers fall back to the
/// DB-stored [TradeListItem.status] via [AsyncValue.whenOrNull].
/// reflects the real relay status. When the order is no longer in the in-memory
/// order book (e.g. after cancellation), falls back to the persisted trade DB
/// so terminal statuses like Canceled are reflected in the UI.
final tradeStatusProvider =
StreamProvider.family.autoDispose<OrderStatus, String>((ref, orderId) async* {
while (true) {
final info = await orders_api.getOrder(orderId: orderId);
if (info != null) yield info.status;
if (info != null) {
yield info.status;
if (_isTerminal(info.status)) return;
} else {
// Order removed from in-memory book — check the persisted trade DB.
final trades = await orders_api.listTrades();
final trade = trades.where((t) => t.order.id == orderId).firstOrNull;
if (trade != null) {
yield trade.order.status;
// Terminal status — no need to keep polling.
if (_isTerminal(trade.order.status)) return;
}
}
await Future.delayed(const Duration(seconds: 2));
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}
});

/// Whether a status is terminal (no further changes possible).
bool _isTerminal(OrderStatus s) => const {
OrderStatus.success,
OrderStatus.settledHoldInvoice,
OrderStatus.settledByAdmin,
OrderStatus.completedByAdmin,
OrderStatus.canceled,
OrderStatus.expired,
OrderStatus.cooperativelyCanceled,
OrderStatus.canceledByAdmin,
}.contains(s);

/// Loads the buyer/seller role for a trade from the persistent DB.
///
/// Returns `true` when the local user is the buyer, `false` for seller, or
Expand Down
39 changes: 28 additions & 11 deletions lib/features/order/screens/my_order_screen.dart
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import 'package:intl/intl.dart';
import 'package:mostro/core/app_routes.dart';
import 'package:mostro/core/app_theme.dart';
import 'package:mostro/features/home/providers/home_order_providers.dart';
import 'package:mostro/features/trades/providers/trades_providers.dart';
import 'package:mostro/l10n/app_localizations.dart';
import 'package:mostro/shared/utils/fiat_currencies.dart';
import 'package:mostro/src/rust/api/orders.dart' as orders_api;
Expand Down Expand Up @@ -58,6 +59,8 @@ class _MyOrderScreenState extends ConsumerState<MyOrderScreen> {
setState(() => _cancelling = true);
try {
await orders_api.cancelOrder(orderId: widget.orderId);
// Force the trades list to reload from DB so the Canceled status shows.
ref.invalidate(rawTradesProvider);
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(l10n.orderCancelledSuccess)),
Expand All @@ -77,7 +80,20 @@ class _MyOrderScreenState extends ConsumerState<MyOrderScreen> {
@override
Widget build(BuildContext context) {
final orders = ref.watch(orderBookProvider).valueOrNull ?? [];
final order = orders.where((o) => o.id == widget.orderId).firstOrNull;
var order = orders.where((o) => o.id == widget.orderId).firstOrNull;
// Fallback to the persisted trade DB when the order is no longer in the
// in-memory order book (e.g. it was taken and moved out of pending).
if (order == null) {
final tradeInfo = ref.watch(tradeInfoProvider(widget.orderId));
if (tradeInfo.valueOrNull?.order != null) {
order = OrderItem.fromInfo(tradeInfo.value!.order);
} else if (tradeInfo.isLoading) {
return Scaffold(
appBar: AppBar(title: const Text('')),
body: const Center(child: CircularProgressIndicator()),
);
}
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
final theme = Theme.of(context);
final colors = theme.extension<AppColors>();
final green = colors?.mostroGreen ?? const Color(0xFF8CC63F);
Expand All @@ -93,12 +109,13 @@ class _MyOrderScreenState extends ConsumerState<MyOrderScreen> {
body: Center(child: Text(l10nNull.orderNotFoundMessage)),
);
}
final resolvedOrder = order;

final l10n = AppLocalizations.of(context);
final flag = flags[order.fiatCode] ?? '';
final isSelling = order.kind == 'sell';
final flag = flags[resolvedOrder.fiatCode] ?? '';
final isSelling = resolvedOrder.kind == 'sell';
final title = isSelling ? l10n.myOrderSellTitle : l10n.myOrderBuyTitle;
final premiumPositive = order.premium >= 0;
final premiumPositive = resolvedOrder.premium >= 0;

return Scaffold(
appBar: AppBar(title: Text(title)),
Expand All @@ -112,12 +129,12 @@ class _MyOrderScreenState extends ConsumerState<MyOrderScreen> {
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'${order.displayAmount} ${order.fiatCode} $flag',
'${resolvedOrder.displayAmount} ${resolvedOrder.fiatCode} $flag',
style: theme.textTheme.headlineMedium,
),
const SizedBox(height: AppSpacing.xs),
Text(
'Market Price (${premiumPositive ? '+' : ''}${order.premium.toStringAsFixed(1)}%)',
'Market Price (${premiumPositive ? '+' : ''}${resolvedOrder.premium.toStringAsFixed(1)}%)',
style: TextStyle(
color: premiumPositive ? green : sellColor,
fontSize: 13,
Expand All @@ -137,7 +154,7 @@ class _MyOrderScreenState extends ConsumerState<MyOrderScreen> {
const SizedBox(width: AppSpacing.sm),
Expanded(
child: Text(
order.paymentMethod,
resolvedOrder.paymentMethod,
style: theme.textTheme.bodyMedium,
),
),
Expand All @@ -154,7 +171,7 @@ class _MyOrderScreenState extends ConsumerState<MyOrderScreen> {
Icon(Icons.calendar_today_outlined, size: 18, color: textSec),
const SizedBox(width: AppSpacing.sm),
Text(
_formatDate(order.createdAt, Localizations.localeOf(context).toString()),
_formatDate(resolvedOrder.createdAt, Localizations.localeOf(context).toString()),
style: theme.textTheme.bodyMedium,
),
],
Expand All @@ -169,7 +186,7 @@ class _MyOrderScreenState extends ConsumerState<MyOrderScreen> {
children: [
Expanded(
child: Text(
order.id,
resolvedOrder.id,
style: theme.textTheme.bodySmall!.copyWith(
fontFamily: 'monospace',
),
Expand All @@ -178,7 +195,7 @@ class _MyOrderScreenState extends ConsumerState<MyOrderScreen> {
),
IconButton(
onPressed: () {
Clipboard.setData(ClipboardData(text: order.id));
Clipboard.setData(ClipboardData(text: resolvedOrder.id));
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(l10n.orderIdCopied),
Expand All @@ -200,7 +217,7 @@ class _MyOrderScreenState extends ConsumerState<MyOrderScreen> {
_InfoCard(
color: cardBg,
child: Builder(builder: (ctx) {
final status = _statusInfo(ctx, order.status);
final status = _statusInfo(ctx, resolvedOrder.status);
return Row(
children: [
Icon(status.icon, size: 18, color: status.color),
Expand Down
82 changes: 52 additions & 30 deletions lib/features/trades/screens/trade_detail_screen.dart
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import 'package:mostro/features/account/providers/privacy_mode_provider.dart';
import 'package:mostro/features/disputes/providers/disputes_providers.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/trades/providers/trades_providers.dart';
import 'package:mostro/features/trades/widgets/release_confirmation_dialog.dart';
import 'package:mostro/features/trades/widgets/trade_info_cards.dart';
import 'package:mostro/shared/widgets/mostro_reactive_button.dart';
Expand All @@ -38,6 +39,8 @@ const _kCountdownSeconds = 900; // 15 minutes
enum TradeStatus {
/// Status not yet resolved (initial loading state — no actions shown).
loading('Loading'),
/// Order published but not yet taken by a counterpart.
pending('Pending'),
/// Buyer must submit Lightning invoice (waitingBuyerInvoice).
waitingInvoice('Waiting Invoice'),
/// Seller must pay hold invoice (waitingPayment).
Expand Down Expand Up @@ -120,33 +123,22 @@ class _TradeDetailScreenState extends ConsumerState<TradeDetailScreen> {
'${dt.hour.toString().padLeft(2, '0')}:'
'${dt.minute.toString().padLeft(2, '0')}';

static TradeStatus _mapOrderStatus(OrderStatus s) {
switch (s) {
case OrderStatus.waitingBuyerInvoice:
return TradeStatus.waitingInvoice;
case OrderStatus.waitingPayment:
return TradeStatus.waitingPayment;
case OrderStatus.active:
case OrderStatus.inProgress:
return TradeStatus.active;
case OrderStatus.fiatSent:
return TradeStatus.fiatSent;
case OrderStatus.settledHoldInvoice:
case OrderStatus.success:
case OrderStatus.completedByAdmin:
case OrderStatus.settledByAdmin:
return TradeStatus.pendingRating;
case OrderStatus.canceled:
case OrderStatus.canceledByAdmin:
case OrderStatus.cooperativelyCanceled:
case OrderStatus.expired:
return TradeStatus.cancelled;
case OrderStatus.dispute:
return TradeStatus.disputed;
default:
return TradeStatus.loading;
}
}
static TradeStatus _mapOrderStatus(OrderStatus s) => switch (s) {
OrderStatus.pending => TradeStatus.pending,
OrderStatus.waitingBuyerInvoice => TradeStatus.waitingInvoice,
OrderStatus.waitingPayment => TradeStatus.waitingPayment,
OrderStatus.active || OrderStatus.inProgress => TradeStatus.active,
OrderStatus.fiatSent => TradeStatus.fiatSent,
OrderStatus.settledHoldInvoice ||
OrderStatus.success ||
OrderStatus.completedByAdmin ||
OrderStatus.settledByAdmin => TradeStatus.pendingRating,
OrderStatus.canceled ||
OrderStatus.canceledByAdmin ||
OrderStatus.cooperativelyCanceled ||
OrderStatus.expired => TradeStatus.cancelled,
OrderStatus.dispute => TradeStatus.disputed,
};

Future<void> _cancelOrder() async {
final l10n = AppLocalizations.of(context);
Expand All @@ -170,6 +162,7 @@ class _TradeDetailScreenState extends ConsumerState<TradeDetailScreen> {
if (confirmed != true || !mounted) return;
try {
await orders_api.cancelOrder(orderId: widget.orderId);
ref.invalidate(rawTradesProvider);
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(l10n.cancelRequestSent)),
Expand Down Expand Up @@ -222,6 +215,10 @@ class _TradeDetailScreenState extends ConsumerState<TradeDetailScreen> {
if (status == TradeStatus.rated) {
return 'Thank you for your rating!';
}
if (status == TradeStatus.pending) {
return 'Your order is published and waiting for a counterpart to take it. '
'You can cancel it at any time.';
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
return 'Trade in progress.';
}

Expand Down Expand Up @@ -329,9 +326,17 @@ class _TradeDetailScreenState extends ConsumerState<TradeDetailScreen> {
}

// Derive trade status from the polled order status.
final orderStatus = ref.watch(tradeStatusProvider(widget.orderId)).valueOrNull
?? OrderStatus.pending;
final status = _mapOrderStatus(orderStatus);
// Use TradeStatus.loading while the provider hasn't resolved so the UI
// doesn't flash the pending CTA before the real status is known.
final tradeStatusAsync = ref.watch(tradeStatusProvider(widget.orderId));
final TradeStatus status;
if (tradeStatusAsync.hasValue) {
status = _mapOrderStatus(tradeStatusAsync.value!);
} else if (tradeStatusAsync.hasError) {
status = TradeStatus.loading;
} else {
status = TradeStatus.loading;
}

// Look up order details from the live order book.
final allOrders = ref.watch(orderBookProvider).valueOrNull ?? [];
Expand Down Expand Up @@ -447,6 +452,23 @@ class _TradeDetailScreenState extends ConsumerState<TradeDetailScreen> {
const SizedBox(height: AppSpacing.xl),
],

// ── Pending — CANCEL button (maker can cancel before taken) ──
if (status == TradeStatus.pending) ...[
OutlinedButton.icon(
onPressed: _cancelOrder,
icon: const Icon(Icons.cancel_outlined, size: 16),
label: const Text('CANCEL'),
style: OutlinedButton.styleFrom(
foregroundColor: colors?.destructiveRed ?? const Color(0xFFD84D4D),
side: BorderSide(color: colors?.destructiveRed ?? const Color(0xFFD84D4D)),
minimumSize: const Size.fromHeight(40),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(AppRadius.button),
),
),
),
],
Comment thread
coderabbitai[bot] marked this conversation as resolved.

// ── Buyer: Waiting Invoice — ADD INVOICE button ────────
if (isBuyer && status == TradeStatus.waitingInvoice) ...[
FilledButton.icon(
Expand Down
15 changes: 10 additions & 5 deletions lib/features/trades/screens/trades_screen.dart
Original file line number Diff line number Diff line change
Expand Up @@ -99,11 +99,16 @@ class _TradesScreenState extends ConsumerState<TradesScreen> {
),
),
loading: () => const Center(child: CircularProgressIndicator()),
error: (e, _) => _ErrorState(
colors: colors,
onRetry: () =>
ref.invalidate(filteredTradesWithOrderStateProvider),
),
error: (e, st) {
debugPrint('[TradesScreen] load error: $e\n$st');
return _ErrorState(
colors: colors,
onRetry: () {
ref.invalidate(rawTradesProvider);
ref.invalidate(filteredTradesWithOrderStateProvider);
},
);
},
),
),
],
Expand Down
9 changes: 7 additions & 2 deletions lib/features/trades/widgets/trades_list_item.dart
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,8 @@ import 'package:mostro/features/trades/providers/trades_providers.dart';
/// Watches [tradeStatusProvider] to keep the status chip current without
/// requiring a full list reload.
///
/// Tap → navigates to `/trade_detail/:orderId`.
/// Tap → navigates to `/my_order/:orderId` for pending creator orders,
/// or `/trade_detail/:orderId` otherwise (active creators and all takers).
class TradesListItem extends ConsumerWidget {
const TradesListItem({
super.key,
Expand Down Expand Up @@ -58,7 +59,11 @@ class TradesListItem extends ConsumerWidget {
),
child: InkWell(
borderRadius: BorderRadius.circular(AppRadius.card),
onTap: () => context.push(AppRoute.tradeDetailPath(trade.orderId)),
onTap: () => context.push(
trade.role == TradeRole.creator && effectiveStatus == TradeStatusFilter.pending
? AppRoute.myOrderPath(trade.orderId)
: AppRoute.tradeDetailPath(trade.orderId),
),
Comment thread
coderabbitai[bot] marked this conversation as resolved.
child: Padding(
padding: const EdgeInsets.symmetric(
horizontal: AppSpacing.md,
Expand Down
37 changes: 36 additions & 1 deletion rust/src/api/orders.rs
Original file line number Diff line number Diff line change
Expand Up @@ -748,6 +748,25 @@ pub async fn cancel_order(order_id: String) -> Result<()> {
let mostro_pubkey = nostr_sdk::PublicKey::from_hex(&active_mostro_pubkey())?;
let event_json = actions::cancel(&sender_keys, &mostro_pubkey, &order_id, trade_index).await?;
publish_event_json(&event_json).await?;

// Optimistic update: mark the trade as Canceled in the local DB immediately
// so the UI reflects the change without waiting for the daemon's gift-wrap
// response. Also remove the order from the in-memory order book.
order_book().remove_order(&order_id).await;
if let Some(db) = crate::db::app_db::db() {
if let Err(e) = db
.update_trade_fields(
&order_id,
Some(crate::api::types::OrderStatus::Canceled),
None,
None,
)
.await
{
log::warn!("[orders] failed to optimistically update cancel status for {order_id}: {e}");
}
}
Comment thread
grunch marked this conversation as resolved.
Comment thread
grunch marked this conversation as resolved.

log::info!("[orders] cancel published for order={order_id} trade_index={trade_index}");
Ok(())
}
Expand Down Expand Up @@ -904,7 +923,23 @@ async fn process_gift_wrap_rumor(rumor_json: &str, trade_pubkey_hex: &str) {
Action::Canceled => {
log::info!("[orders] gift-wrap Canceled for trade={trade_pubkey_hex}");
if let Some(order_id) = &kind.id {
order_book().remove_order(&order_id.to_string()).await;
let oid = order_id.to_string();
order_book().remove_order(&oid).await;
// Sync the Canceled status into the trade DB so My Trades
// reflects the cancellation immediately.
if let Some(db) = crate::db::app_db::db() {
if let Err(e) = db
.update_trade_fields(
&oid,
Some(crate::api::types::OrderStatus::Canceled),
None,
None,
)
.await
{
log::warn!("[orders] failed to sync Canceled status for {oid}: {e}");
}
}
}
}
// Seller receives BuyerTookOrder → peer is buyer_trade_pubkey.
Expand Down
Loading