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
64 changes: 60 additions & 4 deletions lib/data/repositories/dispute_repository.dart
Original file line number Diff line number Diff line change
@@ -1,10 +1,66 @@
import 'package:collection/collection.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:logger/logger.dart';
import 'package:mostro_mobile/data/models/dispute.dart';
import 'package:mostro_mobile/data/models/mostro_message.dart';
import 'package:mostro_mobile/data/models/enums/action.dart';
import 'package:mostro_mobile/services/nostr_service.dart';
import 'package:mostro_mobile/shared/providers/session_notifier_provider.dart';

/// Stub repository for disputes - UI only implementation
/// Repository for managing dispute creation
class DisputeRepository {
static final DisputeRepository _instance = DisputeRepository._internal();
factory DisputeRepository() => _instance;
DisputeRepository._internal();
final NostrService _nostrService;
final String _mostroPubkey;
final Ref _ref;
final Logger _logger = Logger();

DisputeRepository(this._nostrService, this._mostroPubkey, this._ref);

/// Create a new dispute for an order
Future<bool> createDispute(String orderId) async {
try {
_logger.d('Creating dispute for order: $orderId');

Comment on lines +19 to +23
Copy link
Contributor

Choose a reason for hiding this comment

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

🛠️ Refactor suggestion

Validate input: reject empty orderId upfront.

Prevents constructing/sending malformed events.

   Future<bool> createDispute(String orderId) async {
     try {
+      if (orderId.trim().isEmpty) {
+        _logger.e('Empty orderId, cannot create dispute');
+        return false;
+      }
       _logger.d('Creating dispute for order: $orderId');
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
/// Create a new dispute for an order
Future<bool> createDispute(String orderId) async {
try {
_logger.d('Creating dispute for order: $orderId');
/// Create a new dispute for an order
Future<bool> createDispute(String orderId) async {
try {
if (orderId.trim().isEmpty) {
_logger.e('Empty orderId, cannot create dispute');
return false;
}
_logger.d('Creating dispute for order: $orderId');
// ... rest of implementation ...
} catch (e, s) {
_logger.e('Failed to create dispute', e, s);
return false;
}
}

// Get user's session for the order to get the trade key
final sessions = _ref.read(sessionNotifierProvider);
final session = sessions.firstWhereOrNull(
(s) => s.orderId == orderId,
);

if (session == null) {
_logger
.e('No session found for order: $orderId, cannot create dispute');
return false;
}

// Validate trade key is present
if (session.tradeKey.private.isEmpty) {
_logger.e('Trade key is empty for order: $orderId, cannot create dispute');
return false;
}

// Create dispute message using Gift Wrap protocol (NIP-59)
final disputeMessage = MostroMessage(
action: Action.dispute,
id: orderId,
);

// Wrap message using Gift Wrap protocol (NIP-59)
final event = await disputeMessage.wrap(
tradeKey: session.tradeKey,
recipientPubKey: _mostroPubkey,
);

// Send the wrapped event to Mostro
await _nostrService.publishEvent(event);

_logger.d('Successfully sent dispute creation for order: $orderId');
return true;
} catch (e) {
_logger.e('Failed to create dispute: $e');
return false;
}
}

Future<List<Dispute>> getUserDisputes() async {
// Mock implementation for UI testing
Expand Down
12 changes: 12 additions & 0 deletions lib/features/disputes/providers/dispute_providers.dart
Original file line number Diff line number Diff line change
@@ -1,8 +1,20 @@
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/data/repositories/dispute_repository.dart';
import 'package:mostro_mobile/features/disputes/notifiers/dispute_chat_notifier.dart';
import 'package:mostro_mobile/features/disputes/data/dispute_mock_data.dart';
import 'package:mostro_mobile/shared/providers/nostr_service_provider.dart';
import 'package:mostro_mobile/features/settings/settings_provider.dart';

/// Provider for the dispute repository
final disputeRepositoryProvider = Provider.autoDispose<DisputeRepository>((ref) {
final nostrService = ref.watch(nostrServiceProvider);
final settings = ref.watch(settingsProvider);
final mostroPubkey = settings.mostroPublicKey;

return DisputeRepository(nostrService, mostroPubkey, ref);
});

/// Provider for dispute details - uses mock data when enabled
final disputeDetailsProvider = FutureProvider.family<Dispute?, String>((ref, disputeId) async {
Expand Down
32 changes: 31 additions & 1 deletion lib/features/trades/screens/trade_detail_screen.dart
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import 'package:mostro_mobile/features/mostro/mostro_instance.dart';
import 'package:mostro_mobile/shared/providers/mostro_storage_provider.dart';
import 'package:mostro_mobile/data/models/mostro_message.dart';
import 'package:mostro_mobile/shared/providers/time_provider.dart';
import 'package:mostro_mobile/features/disputes/providers/dispute_providers.dart';
import 'package:mostro_mobile/generated/l10n.dart';

class TradeDetailScreen extends ConsumerWidget {
Expand Down Expand Up @@ -711,7 +712,36 @@ class TradeDetailScreen extends ConsumerWidget {

// Only proceed with dispute if user confirmed
if (result == true) {
ref.read(orderNotifierProvider(orderId).notifier).disputeOrder();
try {
// Create dispute using the repository
final repository = ref.read(disputeRepositoryProvider);
final success = await repository.createDispute(orderId);

if (success && context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(S.of(context)!.disputeCreatedSuccessfully),
backgroundColor: AppTheme.mostroGreen,
),
);
} else if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(S.of(context)!.disputeCreationFailed),
backgroundColor: AppTheme.red1,
),
);
}
} catch (e) {
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(S.of(context)!.disputeCreationErrorWithMessage(e.toString())),
backgroundColor: AppTheme.red1,
),
);
}
}
}
},
style: ElevatedButton.styleFrom(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -213,7 +213,7 @@ class MostroMessageDetail extends ConsumerWidget {
.of(context)!
.disputeInitiatedByPeer(orderPayload!.id!, payload!.disputeId);
case actions.Action.adminTookDispute:
return S.of(context)!.adminTookDisputeUsers('{admin token}');
return S.of(context)!.adminTookDisputeUsers;
case actions.Action.adminCanceled:
return S.of(context)!.adminCanceledUsers(orderPayload!.id ?? '');
case actions.Action.adminSettled:
Expand Down
40 changes: 37 additions & 3 deletions lib/l10n/intl_en.arb
Original file line number Diff line number Diff line change
Expand Up @@ -106,10 +106,34 @@
"cooperativeCancelInitiatedByYou": "You have initiated the cancellation of the order ID: {id}. Your counterparty must agree. If they do not respond, you can open a dispute. Note that no administrator will contact you regarding this cancellation unless you open a dispute first.",
"cooperativeCancelInitiatedByPeer": "Your counterparty wants to cancel order ID: {id}. If you agree, please send me cancel-order-message. Note that no administrator will contact you regarding this cancellation unless you open a dispute first.",
"cooperativeCancelAccepted": "Order {id} has been successfully canceled!",
"disputeInitiatedByYou": "You have initiated a dispute for order Id: {id}. A solver will be assigned soon. Once assigned, I will share their npub with you, and only they will be able to assist you. Your dispute token is: {user_token}.",
"disputeInitiatedByPeer": "Your counterparty has initiated a dispute for order Id: {id}. A solver will be assigned soon. Once assigned, I will share their npub with you, and only they will be able to assist you. Your dispute token is: {user_token}.",
"disputeInitiatedByYou": "You have initiated a dispute for order ID: {id}. A solver will be assigned soon. Once assigned, I will share their npub with you, and only they will be able to assist you. Your dispute ID is: {dispute_id}.",
"@disputeInitiatedByYou": {
"placeholders": {
"id": {
"type": "String",
"example": "abc123"
},
"dispute_id": {
"type": "String",
"example": "dispute-456"
}
}
},
"disputeInitiatedByPeer": "Your counterparty has initiated a dispute for order ID: {id}. A solver will be assigned soon. Once assigned, I will share their npub with you, and only they will be able to assist you. Your dispute ID is: {dispute_id}.",
"@disputeInitiatedByPeer": {
"placeholders": {
"id": {
"type": "String",
"example": "abc123"
},
"dispute_id": {
"type": "String",
"example": "dispute-456"
}
}
},
"adminTookDisputeAdmin": "Here are the details of the dispute order you have taken: {details}. You need to determine which user is correct and decide whether to cancel or complete the order. Please note that your decision will be final and cannot be reversed.",
"adminTookDisputeUsers": "The solver {admin_npub} will handle your dispute. You can contact them directly, but if they reach out to you first, make sure to ask them for your dispute token.",
"adminTookDisputeUsers": "A dispute resolver has been assigned to handle your dispute. They will contact you through the app.",
"adminCanceledAdmin": "You have canceled the order ID: {id}.",
"adminCanceledUsers": "Admin has canceled the order ID: {id}.",
"adminSettledAdmin": "You have completed the order ID: {id}.",
Expand Down Expand Up @@ -267,6 +291,16 @@
"orderIdCopiedMessage": "Order ID copied to clipboard",
"disputeTradeDialogTitle": "Start Dispute",
"disputeTradeDialogContent": "You are about to start a dispute with your counterparty. Do you want to continue?",
"disputeCreatedSuccessfully": "Dispute created successfully",
"disputeCreationFailed": "Failed to create dispute",
"disputeCreationErrorWithMessage": "Error creating dispute: {error}",
"@disputeCreationErrorWithMessage": {
"placeholders": {
"error": {
"type": "String"
}
}
},
Comment on lines +294 to +303
Copy link
Contributor

Choose a reason for hiding this comment

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

💡 Verification agent

🧩 Analysis chain

New dispute-creation snackbar keys — LGTM; ensure locale parity

Strings read well and the error placeholder is typed. Per guidelines, confirm these keys exist in intl_es.arb and intl_it.arb.

Run to verify presence across locales and metadata health:


🏁 Script executed:

#!/bin/bash
set -euo pipefail

files=(lib/l10n/intl_en.arb lib/l10n/intl_es.arb lib/l10n/intl_it.arb)
for f in "${files[@]}"; do
  test -f "$f" || { echo "Missing $f"; exit 1; }
done

keys=('disputeCreatedSuccessfully' 'disputeCreationFailed' 'disputeCreationErrorWithMessage' 'disputeInitiatedByYou' 'disputeInitiatedByPeer' 'adminTookDisputeUsers')
for k in "${keys[@]}"; do
  for f in "${files[@]}"; do
    jq -e --arg k "$k" 'has($k)' "$f" >/dev/null || { echo "$f is missing key: $k"; exit 1; }
  done
done

# EN-specific metadata checks
jq -e '.["@disputeCreationErrorWithMessage"].placeholders.error' lib/l10n/intl_en.arb >/dev/null || echo "Note: missing error placeholder metadata in en"
jq -e '.["@adminTookDisputeUsers"].placeholders.admin_npub' lib/l10n/intl_en.arb >/dev/null || echo "Note: add @adminTookDisputeUsers metadata with admin_npub placeholder in en"

for k in disputeInitiatedByYou disputeInitiatedByPeer; do
  jq -e --arg k "$k" '.["@"+$k].placeholders.id and .["@"+$k].placeholders.dispute_id' lib/l10n/intl_en.arb >/dev/null \
    || echo "Note: ensure id and dispute_id placeholders are declared for $k in en"
done

echo "OK: localization keys present in en/es/it"

Length of output: 3101


Add admin_npub placeholder metadata for adminTookDisputeUsers
In lib/l10n/intl_en.arb, append the following metadata block:

"@adminTookDisputeUsers": {
  "placeholders": {
    "admin_npub": { "type": "String" }
  }
}
🤖 Prompt for AI Agents
In lib/l10n/intl_en.arb around lines 294 to 303, the translation key
adminTookDisputeUsers is missing a placeholder metadata block for admin_npub;
add an entry "@adminTookDisputeUsers" with a placeholders object that defines
"admin_npub" of type "String" so the localization tooling recognizes the
placeholder and validates/generated code correctly.

"typeToAdd": "Type to add...",
"noneSelected": "None selected",
"fiatCurrencies": "Fiat currencies",
Expand Down
16 changes: 13 additions & 3 deletions lib/l10n/intl_es.arb
Original file line number Diff line number Diff line change
Expand Up @@ -106,10 +106,10 @@
"cooperativeCancelInitiatedByYou": "Has iniciado la cancelación de la orden ID: {id}. Tu contraparte debe estar de acuerdo. Si no responden, puedes abrir una disputa. Ten en cuenta que ningún administrador te contactará sobre esta cancelación a menos que abras una disputa primero.",
"cooperativeCancelInitiatedByPeer": "Tu contraparte quiere cancelar la orden ID: {id}. Si estás de acuerdo, por favor envíame cancel-order-message. Ten en cuenta que ningún administrador te contactará sobre esta cancelación a menos que abras una disputa primero.",
"cooperativeCancelAccepted": "¡La orden {id} ha sido cancelada exitosamente!",
"disputeInitiatedByYou": "Has iniciado una disputa para la orden Id: {id}. Un resolutor será asignado pronto. Una vez asignado, compartiré su npub contigo, y solo ellos podrán ayudarte. Tu token de disputa es: {user_token}.",
"disputeInitiatedByPeer": "Tu contraparte ha iniciado una disputa para la orden Id: {id}. Un resolutor será asignado pronto. Una vez asignado, compartiré su npub contigo, y solo ellos podrán ayudarte. Tu token de disputa es: {user_token}.",
"disputeInitiatedByYou": "Has iniciado una disputa para la orden Id: {id}. Un resolutor será asignado pronto. Una vez asignado, compartiré su npub contigo, y solo ellos podrán ayudarte. Tu ID de disputa es: {dispute_id}.",
"disputeInitiatedByPeer": "Tu contraparte ha iniciado una disputa para la orden Id: {id}. Un resolutor será asignado pronto. Una vez asignado, compartiré su npub contigo, y solo ellos podrán ayudarte. Tu ID de disputa es: {dispute_id}.",
"adminTookDisputeAdmin": "Aquí están los detalles de la orden en disputa que has tomado: {details}. Necesitas determinar qué usuario es correcto y decidir si cancelar o completar la orden. Ten en cuenta que tu decisión será final y no se puede revertir.",
"adminTookDisputeUsers": "El resolutor {admin_npub} manejará tu disputa. Puedes contactarlos directamente, pero si te contactan primero, asegúrate de pedirles tu token de disputa.",
"adminTookDisputeUsers": "Se ha asignado un resolutor de disputas para manejar tu caso. Te contactarán a través de la aplicación.",
"adminCanceledAdmin": "Has cancelado la orden ID: {id}.",
"adminCanceledUsers": "El administrador ha cancelado la orden ID: {id}.",
"adminSettledAdmin": "Has completado la orden ID: {id}.",
Expand Down Expand Up @@ -608,6 +608,16 @@
"orderIdCopiedMessage": "ID de orden copiado al portapapeles",
"disputeTradeDialogTitle": "Iniciar Disputa",
"disputeTradeDialogContent": "Estás a punto de iniciar una disputa con tu contraparte. ¿Deseas continuar?",
"disputeCreatedSuccessfully": "Disputa creada exitosamente",
"disputeCreationFailed": "Falló la creación de la disputa",
"disputeCreationErrorWithMessage": "Error al crear la disputa: {error}",
"@disputeCreationErrorWithMessage": {
"placeholders": {
"error": {
"type": "String"
}
}
},

"language": "Idioma",
"systemDefault": "Predeterminado",
Expand Down
40 changes: 37 additions & 3 deletions lib/l10n/intl_it.arb
Original file line number Diff line number Diff line change
Expand Up @@ -106,10 +106,34 @@
"cooperativeCancelInitiatedByYou": "Hai iniziato l'annullamento dell'ordine ID: {id}. La tua controparte deve anche concordare l'annullamento. Se non risponde, puoi aprire una disputa. Nota che nessun amministratore ti contatterà MAI riguardo questo annullamento a meno che tu non apra prima una disputa.",
"cooperativeCancelInitiatedByPeer": "La tua controparte vuole annullare l'ordine ID: {id}. Nota che nessun amministratore ti contatterà MAI riguardo questo annullamento a meno che tu non apra prima una disputa. Se concordi su tale annullamento, premi: Annulla Ordine.",
"cooperativeCancelAccepted": "L'ordine {id} è stato annullato con successo!",
"disputeInitiatedByYou": "Hai iniziato una disputa per l'ordine ID: {id}. Un amministratore sarà assegnato presto alla tua disputa. Una volta assegnato, riceverai il suo npub e solo questo account potrà assisterti. Devi contattare l'amministratore direttamente, ma se qualcuno ti contatta prima, assicurati di chiedergli di fornirti il token per la tua disputa. Il token di questa disputa è: {user_token}.",
"disputeInitiatedByPeer": "La tua controparte ha iniziato una disputa per l'ordine ID: {id}. Un amministratore sarà assegnato presto alla tua disputa. Una volta assegnato, ti condividerò il loro npub e solo loro potranno assisterti. Devi contattare l'amministratore direttamente, ma se qualcuno ti contatta prima, assicurati di chiedergli di fornirti il token per la tua disputa. Il token di questa disputa è: {user_token}.",
"disputeInitiatedByYou": "Hai iniziato una disputa per l'ordine ID: {id}. Un amministratore sarà assegnato presto alla tua disputa. Una volta assegnato, riceverai il suo npub e solo questo account potrà assisterti. Devi contattare l'amministratore direttamente, ma se qualcuno ti contatta prima, assicurati di chiedergli di fornirti l'ID per la tua disputa. L'ID di questa disputa è: {dispute_id}.",
"disputeInitiatedByPeer": "La tua controparte ha iniziato una disputa per l'ordine ID: {id}. Un amministratore sarà assegnato presto alla tua disputa. Una volta assegnato, ti condividerò il loro npub e solo loro potranno assisterti. Devi contattare l'amministratore direttamente, ma se qualcuno ti contatta prima, assicurati di chiedergli di fornirti l'ID per la tua disputa. L'ID di questa disputa è: {dispute_id}.",
"@disputeInitiatedByPeer": {
"placeholders": {
"id": {
"type": "String",
"description": "ID dell'ordine"
},
"dispute_id": {
"type": "String",
"description": "ID della disputa"
}
}
},
"@disputeInitiatedByYou": {
"placeholders": {
"id": {
"type": "String",
"description": "ID dell'ordine"
},
"dispute_id": {
"type": "String",
"description": "ID della disputa"
}
}
},
"adminTookDisputeAdmin": "Ecco i dettagli dell'ordine della disputa che hai preso: {details}. Devi determinare quale utente ha ragione e decidere se annullare o completare l'ordine. Nota che la tua decisione sarà finale e non può essere annullata.",
"adminTookDisputeUsers": "L'amministratore {admin_npub} gestirà la tua disputa. Devi contattare l'amministratore direttamente, ma se qualcuno ti contatta prima, assicurati di chiedergli di fornirti il token per la tua disputa..",
"adminTookDisputeUsers": "È stato assegnato un risolutore di controversie per gestire il tuo caso. Ti contatteranno attraverso l'applicazione.",
"adminCanceledAdmin": "Hai annullato l'ordine ID: {id}!",
"adminCanceledUsers": "L'amministratore ha annullato l'ordine ID: {id}!",
"adminSettledAdmin": "Hai completato l'ordine ID: {id}!",
Expand Down Expand Up @@ -649,6 +673,16 @@
"orderIdCopiedMessage": "ID ordine copiato negli appunti",
"disputeTradeDialogTitle": "Inizia Disputa",
"disputeTradeDialogContent": "Stai per iniziare una disputa con la tua controparte. Vuoi continuare?",
"disputeCreatedSuccessfully": "Disputa creata con successo",
"disputeCreationFailed": "Creazione della disputa fallita",
"disputeCreationErrorWithMessage": "Errore nella creazione della disputa: {error}",
"@disputeCreationErrorWithMessage": {
"placeholders": {
"error": {
"type": "String"
}
}
},

"language": "Lingua",
"systemDefault": "Predefinito di sistema",
Expand Down
43 changes: 37 additions & 6 deletions lib/services/dispute_service.dart
Original file line number Diff line number Diff line change
@@ -1,24 +1,55 @@
import 'dart:math';
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<List<Dispute>> getUserDisputes() async {
return await _disputeRepository.getUserDisputes();
// 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<Dispute?> getDispute(String disputeId) async {
return await _disputeRepository.getDispute(disputeId);
// Mock implementation
await Future.delayed(const Duration(milliseconds: 300));

// Safe prefix extraction to avoid RangeError
final prefixLen = min(8, disputeId.length);
final orderIdSuffix = disputeId.isEmpty ? 'unknown' : disputeId.substring(0, prefixLen);

return Dispute(
disputeId: disputeId,
orderId: 'order_$orderIdSuffix',
status: 'initiated',
createdAt: DateTime.now().subtract(const Duration(hours: 2)),
action: 'dispute-initiated-by-you',
);
}

Future<void> sendDisputeMessage(String disputeId, String message) async {
await _disputeRepository.sendDisputeMessage(disputeId, message);
// Mock implementation
await Future.delayed(const Duration(milliseconds: 200));
}

Future<void> initiateDispute(String orderId, String reason) async {
Expand Down