diff --git a/.gitignore b/.gitignore index 612c050b..59ffe6fe 100644 --- a/.gitignore +++ b/.gitignore @@ -70,4 +70,5 @@ chrome/.packages # Misc *.log *.lock -.pdm \ No newline at end of file +.pdm +bfg-1.14.0.jar diff --git a/android/app/build.gradle b/android/app/build.gradle index 933cf738..fba96afe 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -8,7 +8,7 @@ plugins { android { namespace = "com.example.mostro_mobile" compileSdk = flutter.compileSdkVersion - ndkVersion = "25.1.8937393" // flutter.ndkVersion + ndkVersion = "25.1.8937393" //flutter.ndkVersion compileOptions { sourceCompatibility = JavaVersion.VERSION_1_8 diff --git a/l10n.yaml b/l10n.yaml new file mode 100644 index 00000000..c10580f6 --- /dev/null +++ b/l10n.yaml @@ -0,0 +1,3 @@ +arb-dir: lib/l10n +template-arb-file: intl_en.arb +output-dir: lib/generated diff --git a/lib/core/config.dart b/lib/core/config.dart index 4fff4843..41610a77 100644 --- a/lib/core/config.dart +++ b/lib/core/config.dart @@ -1,20 +1,19 @@ -// lib/core/config.dart - import 'package:flutter/foundation.dart'; class Config { // Configuración de Nostr static const List nostrRelays = [ + 'ws://127.0.0.1:7000', //'ws://10.0.2.2:7000', - 'wss://relay.damus.io', - 'wss://relay.mostro.network', - 'wss://relay.nostr.net', + //'wss://relay.damus.io', + //'wss://relay.mostro.network', + //'wss://relay.nostr.net', // Agrega más relays aquí si es necesario ]; - // Npub de Mostro + // hexkey de Mostro static const String mostroPubKey = - 'npub1n5yrh6lkvc0l3lcmcfwake4r3ex7jrm0e6lumsc22d8ylf7jwk0qack9tql;'; + '9d9d0455a96871f2dc4289b8312429db2e925f167b37c77bf7b28014be235980'; // Tiempo de espera para conexiones a relays static const Duration nostrConnectionTimeout = Duration(seconds: 30); diff --git a/lib/core/utils/auth_utils.dart b/lib/core/utils/auth_utils.dart index b1db6816..0cc55d5f 100644 --- a/lib/core/utils/auth_utils.dart +++ b/lib/core/utils/auth_utils.dart @@ -1,33 +1,30 @@ -import 'package:flutter_secure_storage/flutter_secure_storage.dart'; - class AuthUtils { - static const _storage = FlutterSecureStorage(); - - static Future savePrivateKeyAndPin(String privateKey, String pin) async { - await _storage.write(key: 'user_private_key', value: privateKey); - await _storage.write(key: 'user_pin', value: pin); - } + /// Temporary implementation for alpha preview. + /// WARNING: This is not secure and should not be used in production. + /// TODO: Implement secure storage for credentials + static Future savePrivateKeyAndPin( + String privateKey, String pin) async {} + /// Temporary implementation for alpha preview. + /// WARNING: This always returns null and should not be used in production. + /// TODO: Implement secure key retrieval static Future getPrivateKey() async { - return await _storage.read(key: 'user_private_key'); + return null; } static Future verifyPin(String inputPin) async { - final storedPin = await _storage.read(key: 'user_pin'); - return storedPin == inputPin; + throw UnimplementedError('verifyPin is not implemented yet'); } static Future deleteCredentials() async { - await _storage.delete(key: 'user_private_key'); - await _storage.delete(key: 'user_pin'); - await _storage.delete(key: 'use_biometrics'); + throw UnimplementedError('deleteCredentials is not implemented yet'); } static Future enableBiometrics() async { - await _storage.write(key: 'use_biometrics', value: 'true'); + throw UnimplementedError('enableBiometrics is not implemented yet'); } static Future isBiometricsEnabled() async { - return await _storage.read(key: 'use_biometrics') == 'true'; + throw UnimplementedError('isBiometricsEnabled is not implemented yet'); } -} \ No newline at end of file +} diff --git a/lib/core/utils/biometrics_helper.dart b/lib/core/utils/biometrics_helper.dart index da814e07..ff06e547 100644 --- a/lib/core/utils/biometrics_helper.dart +++ b/lib/core/utils/biometrics_helper.dart @@ -19,7 +19,6 @@ class BiometricsHelper { ), ); } catch (e) { - print(e); return false; } } diff --git a/lib/core/utils/nostr_utils.dart b/lib/core/utils/nostr_utils.dart index b4c60a6d..e036aab0 100644 --- a/lib/core/utils/nostr_utils.dart +++ b/lib/core/utils/nostr_utils.dart @@ -1,15 +1,24 @@ import 'dart:convert'; -import 'dart:typed_data'; +import 'dart:math'; import 'package:crypto/crypto.dart'; import 'package:dart_nostr/dart_nostr.dart'; -import 'package:encrypt/encrypt.dart' as encrypt; +import 'package:elliptic/elliptic.dart'; +import 'package:nip44/nip44.dart'; class NostrUtils { static final Nostr _instance = Nostr.instance; // Generación de claves static NostrKeyPairs generateKeyPair() { - return _instance.keysService.generateKeyPair(); + try { + final privateKey = generatePrivateKey(); + if (!isValidPrivateKey(privateKey)) { + throw Exception('Generated invalid private key'); + } + return NostrKeyPairs(private: privateKey); + } catch (e) { + throw Exception('Failed to generate key pair: $e'); + } } static NostrKeyPairs generateKeyPairFromPrivateKey(String privateKey) { @@ -18,7 +27,11 @@ class NostrUtils { } static String generatePrivateKey() { - return _instance.keysService.generatePrivateKey(); + try { + return getS256().generatePrivateKey().toHex(); + } catch (e) { + throw Exception('Failed to generate private key: $e'); + } } // Codificación y decodificación de claves @@ -117,96 +130,175 @@ class NostrUtils { return digest.toString(); // Devuelve el ID como una cadena hex } - // NIP-59 y NIP-44 funciones - static NostrEvent createNIP59Event( - String content, String recipientPubKey, String senderPrivateKey) { - final senderKeyPair = generateKeyPairFromPrivateKey(senderPrivateKey); - final sharedSecret = - _calculateSharedSecret(senderPrivateKey, recipientPubKey); + /// Generates a timestamp between now and 48 hours ago to enhance privacy + /// by decorrelating event timing from creation time. + /// @throws if system clock is ahead of network time + static DateTime randomNow() { + final now = DateTime.now(); + // Validate system time isn't ahead + final networkTime = DateTime.now().toUtc(); + if (now.isAfter(networkTime.add(Duration(minutes: 5)))) { + throw Exception('System clock is ahead of network time'); + } + final randomSeconds = Random().nextInt(2 * 24 * 60 * 60); + return now.subtract(Duration(seconds: randomSeconds)); + } + + /// Creates a NIP-59 encrypted event with the following structure: + /// 1. Inner event (kind 1): Original content + /// 2. Seal event (kind 13): Encrypted inner event + /// 3. Wrapper event (kind 1059): Final encrypted package + static Future createNIP59Event( + String content, String recipientPubKey, String senderPrivateKey) async { + // Validate inputs + if (content.isEmpty) throw ArgumentError('Content cannot be empty'); + if (recipientPubKey.length != 64) { + throw ArgumentError('Invalid recipient public key'); + } + if (!isValidPrivateKey(senderPrivateKey)) { + throw ArgumentError('Invalid sender private key'); + } - final encryptedContent = _encryptNIP44(content, sharedSecret); + final senderKeyPair = generateKeyPairFromPrivateKey(senderPrivateKey); final createdAt = DateTime.now(); - final rumorEvent = NostrEvent( - kind: 1059, - pubkey: senderKeyPair.public, - content: encryptedContent, + final rumorEvent = NostrEvent.fromPartialData( + kind: 1, + keyPairs: senderKeyPair, + content: content, + createdAt: createdAt, tags: [ ["p", recipientPubKey] ], - createdAt: createdAt, - id: '', // Se generará después - sig: '', // Se generará después ); - // Generar ID y firma - final id = generateId({ - 'pubkey': rumorEvent.pubkey, - 'created_at': rumorEvent.createdAt!.millisecondsSinceEpoch ~/ 1000, - 'kind': rumorEvent.kind, - 'tags': rumorEvent.tags, - 'content': rumorEvent.content, - }); - signMessage(id, senderPrivateKey); + String? encryptedContent; + + try { + encryptedContent = await _encryptNIP44( + jsonEncode(rumorEvent.toMap()), senderPrivateKey, recipientPubKey); + } catch (e) { + throw Exception('Failed to encrypt content: $e'); + } + + final sealEvent = NostrEvent.fromPartialData( + kind: 13, + keyPairs: senderKeyPair, + content: encryptedContent, + createdAt: randomNow(), + ); final wrapperKeyPair = generateKeyPair(); - final wrappedContent = _encryptNIP44(jsonEncode(rumorEvent.toMap()), - _calculateSharedSecret(wrapperKeyPair.private, recipientPubKey)); - return NostrEvent( + final pk = wrapperKeyPair.private; + + String sealedContent; + try { + sealedContent = await _encryptNIP44( + jsonEncode(sealEvent.toMap()), pk, '02$recipientPubKey'); + } catch (e) { + throw Exception('Failed to encrypt seal event: $e'); + } + + final wrapEvent = NostrEvent.fromPartialData( kind: 1059, - pubkey: wrapperKeyPair.public, - content: wrappedContent, + content: sealedContent, + keyPairs: wrapperKeyPair, tags: [ ["p", recipientPubKey] ], - createdAt: DateTime.now(), - id: generateId({ - 'pubkey': wrapperKeyPair.public, - 'created_at': DateTime.now().millisecondsSinceEpoch ~/ 1000, - 'kind': 1059, - 'tags': [ - ["p", recipientPubKey] - ], - 'content': wrappedContent, - }), - sig: '', // Se generará automáticamente al publicar el evento + createdAt: createdAt, ); - } - static String decryptNIP59Event(NostrEvent event, String privateKey) { - final sharedSecret = _calculateSharedSecret(privateKey, event.pubkey); - final decryptedContent = _decryptNIP44(event.content ?? '', sharedSecret); + return wrapEvent; + } - final rumorEvent = NostrEvent.deserialized(decryptedContent); - final rumorSharedSecret = - _calculateSharedSecret(privateKey, rumorEvent.pubkey); - final finalDecryptedContent = - _decryptNIP44(rumorEvent.content ?? '', rumorSharedSecret); + static Future decryptNIP59Event( + NostrEvent event, String privateKey) async { + // Validate inputs + if (event.content == null || event.content!.isEmpty) { + throw ArgumentError('Event content is empty'); + } + if (!isValidPrivateKey(privateKey)) { + throw ArgumentError('Invalid private key'); + } - return finalDecryptedContent; + try { + final decryptedContent = + await _decryptNIP44(event.content ?? '', privateKey, event.pubkey); + + final rumorEvent = + NostrEvent.deserialized('["EVENT", "", $decryptedContent]'); + + final finalDecryptedContent = await _decryptNIP44( + rumorEvent.content ?? '', privateKey, rumorEvent.pubkey); + + final wrap = jsonDecode(finalDecryptedContent) as Map; + + // Validate decrypted event structure + _validateEventStructure(wrap); + + return NostrEvent( + id: wrap['id'] as String, + kind: wrap['kind'] as int, + content: wrap['content'] as String, + sig: "", + pubkey: wrap['pubkey'] as String, + createdAt: DateTime.fromMillisecondsSinceEpoch( + (wrap['created_at'] as int) * 1000, + ), + tags: List>.from( + (wrap['tags'] as List) + .map( + (nestedElem) => (nestedElem as List) + .map( + (nestedElemContent) => nestedElemContent.toString(), + ) + .toList(), + ) + .toList(), + ), + subscriptionId: '', + ); + } catch (e) { + throw Exception('Failed to decrypt NIP-59 event: $e'); + } } - static Uint8List _calculateSharedSecret(String privateKey, String publicKey) { - // Nota: Esta implementación puede necesitar ajustes dependiendo de cómo - // dart_nostr maneje la generación de secretos compartidos. - // Posiblemente necesites usar una biblioteca de criptografía adicional aquí. - final sharedPoint = generateKeyPairFromPrivateKey(privateKey).public; - return Uint8List.fromList(sha256.convert(utf8.encode(sharedPoint)).bytes); + /// Validates the structure of a decrypted event + static void _validateEventStructure(Map event) { + final requiredFields = [ + 'id', + 'kind', + 'content', + 'pubkey', + 'created_at', + 'tags' + ]; + for (final field in requiredFields) { + if (!event.containsKey(field)) { + throw FormatException('Missing required field: $field'); + } + } } - static String _encryptNIP44(String content, Uint8List key) { - final iv = encrypt.IV.fromSecureRandom(16); - final encrypter = encrypt.Encrypter(encrypt.AES(encrypt.Key(key))); - final encrypted = encrypter.encrypt(content, iv: iv); - return base64Encode(iv.bytes + encrypted.bytes); + static Future _encryptNIP44( + String content, String privkey, String pubkey) async { + try { + return await Nip44.encryptMessage(content, privkey, pubkey); + } catch (e) { + // Handle encryption error appropriately + throw Exception('Encryption failed: $e'); + } } - static String _decryptNIP44(String encryptedContent, Uint8List key) { - final decoded = base64Decode(encryptedContent); - final iv = encrypt.IV(decoded.sublist(0, 16)); - final encryptedBytes = decoded.sublist(16); - final encrypter = encrypt.Encrypter(encrypt.AES(encrypt.Key(key))); - return encrypter.decrypt64(base64Encode(encryptedBytes), iv: iv); + static Future _decryptNIP44( + String encryptedContent, String privkey, String pubkey) async { + try { + return await Nip44.decryptMessage(encryptedContent, privkey, pubkey); + } catch (e) { + // Handle encryption error appropriately + throw Exception('Decryption failed: $e'); + } } } diff --git a/lib/data/models/amount.dart b/lib/data/models/amount.dart new file mode 100644 index 00000000..972c4cae --- /dev/null +++ b/lib/data/models/amount.dart @@ -0,0 +1,39 @@ +class Amount { + final int minimum; + final int? maximum; + + Amount(this.minimum, this.maximum); + + factory Amount.fromList(List fa) { + if (fa.length < 2) { + throw ArgumentError( + 'List must have at least two elements: a label and a minimum value.'); + } + + final min = int.tryParse(fa[1]); + if (min == null) { + throw ArgumentError( + 'Second element must be a valid integer representing the minimum value.'); + } + + int? max; + if (fa.length > 2) { + max = int.tryParse(fa[2]); + } + + return Amount(min, max); + } + + factory Amount.empty() { + return Amount(0, null); + } + + @override + String toString() { + if (maximum != null) { + return '$minimum - $maximum'; + } else { + return '$minimum'; + } + } +} diff --git a/lib/data/models/content.dart b/lib/data/models/content.dart new file mode 100644 index 00000000..c5a80a11 --- /dev/null +++ b/lib/data/models/content.dart @@ -0,0 +1,16 @@ +import 'package:mostro_mobile/data/models/order.dart'; +import 'package:mostro_mobile/data/models/payment_request.dart'; + +abstract class Content { + String get type; + Map toJson(); + + factory Content.fromJson(Map json) { + if (json.containsKey('order')) { + return Order.fromJson(json['order']); + } else if (json.containsKey('payment_request')) { + return PaymentRequest.fromJson(json['payment_request']); + } + throw UnsupportedError('Unknown content type'); + } +} diff --git a/lib/data/models/enums/action.dart b/lib/data/models/enums/action.dart new file mode 100644 index 00000000..432d33b4 --- /dev/null +++ b/lib/data/models/enums/action.dart @@ -0,0 +1,68 @@ +enum Action { + newOrder('new-order'), + takeSell('take-sell'), + takeBuy('take-buy'), + payInvoice('pay-invoice'), + fiatSent('fiat-sent'), + fiatSentOk('fiat-sent-ok'), + release('release'), + released('released'), + cancel('cancel'), + canceled('canceled'), + cooperativeCancelInitiatedByYou('cooperative-cancel-initiated-by-you'), + cooperativeCancelInitiatedByPeer('cooperative-cancel-initiated-by-peer'), + disputeInitiatedByYou('dispute-initiated-by-you'), + disputeInitiatedByPeer('dispute-initiated-by-peer'), + cooperativeCancelAccepted('cooperative-cancel-accepted'), + buyerInvoiceAccepted('buyer-invoice-accepted'), + purchaseCompleted('purchase-completed'), + holdInvoicePaymentAccepted('hold-invoice-payment-accepted'), + holdInvoicePaymentSettled('hold-invoice-payment-settled'), + holdInvoicePaymentCanceled('hold-invoice-payment-canceled'), + waitingSellerToPay('waiting-seller-to-pay'), + waitingBuyerInvoice('waiting-buyer-invoice'), + addInvoice('add-invoice'), + buyerTookOrder('buyer-took-order'), + rate('rate'), + rateUser('rate-user'), + rateReceived('rate-received'), + cantDo('cant-do'), + dispute('dispute'), + adminCancel('admin-cancel'), + adminCanceled('admin-canceled'), + adminSettle('admin-settle'), + adminSettled('admin-settled'), + adminAddSolver('admin-add-solver'), + adminTakeDispute('admin-take-dispute'), + adminTookDispute('admin-took-dispute'), + isNotYourOrder('is-not-your-order'), + notAllowedByStatus('not-allowed-by-status'), + outOfRangeFiatAmount('out-of-range-fiat-amount'), + isNotYourDispute('is-not-your-dispute'), + notFound('not-found'), + incorrectInvoiceAmount('incorrect-invoice-amount'), + invalidSatsAmount('invalid-sats-amount'), + outOfRangeSatsAmount('out-of-range-sats-amount'), + paymentFailed('payment-failed'), + invoiceUpdated('invoice-updated'); + + final String value; + + const Action(this.value); + + /// Converts a string value to its corresponding Action enum value. + /// + /// Throws an ArgumentError if the string doesn't match any Action value. + static final _valueMap = { + for (var action in Action.values) action.value: action + }; + + static Action fromString(String value) { + final action = _valueMap[value]; + if (action == null) { + throw ArgumentError('Invalid Action: $value'); + } + return action; + } + +} diff --git a/lib/data/models/enums/order_type.dart b/lib/data/models/enums/order_type.dart new file mode 100644 index 00000000..939474f3 --- /dev/null +++ b/lib/data/models/enums/order_type.dart @@ -0,0 +1,19 @@ +enum OrderType { + buy('buy'), + sell('sell'); + + final String value; + + const OrderType(this.value); + + static OrderType fromString(String value) { + switch (value) { + case 'buy': + return OrderType.buy; + case 'sell': + return OrderType.sell; + default: + throw ArgumentError('Invalid OrderType: $value'); + } + } +} diff --git a/lib/data/models/enums/status.dart b/lib/data/models/enums/status.dart new file mode 100644 index 00000000..8d0b6c48 --- /dev/null +++ b/lib/data/models/enums/status.dart @@ -0,0 +1,29 @@ +enum Status { + active('active'), + canceled('canceled'), + canceledByAdmin('canceled-by-admin'), + settledByAdmin('settled-by-admin'), + completedByAdmin('completed-by-admin'), + dispute('dispute'), + expired('expired'), + fiatSent('fiat-sent'), + settledHoldInvoice('settled-hold-invoice'), + pending('pending'), + success('success'), + waitingBuyerInvoice('waiting-buyer-invoice'), + waitingPayment('waiting-payment'), + cooperativelyCanceled('cooperatively-canceled'), + inProgress('in-progress'); + + final String value; + + const Status(this.value); + + static Status fromString(String value) { + return Status.values.firstWhere( + (k) => k.value == value, + orElse: () => throw ArgumentError('Invalid Status: $value'), + ); + } + +} \ No newline at end of file diff --git a/lib/data/models/mostro_message.dart b/lib/data/models/mostro_message.dart new file mode 100644 index 00000000..c60781f3 --- /dev/null +++ b/lib/data/models/mostro_message.dart @@ -0,0 +1,53 @@ +import 'dart:convert'; + +import 'package:mostro_mobile/data/models/enums/action.dart'; +import 'package:mostro_mobile/data/models/content.dart'; +import 'package:mostro_mobile/services/mostro_service.dart'; + +class MostroMessage { + final int version = mostroVersion; + final String? requestId; + final Action action; + T? content; + + MostroMessage({required this.action, required this.requestId, this.content}); + + Map toJson() { + return { + 'order': { + 'version': mostroVersion, + 'id': requestId, + 'action': action.value, + 'content': content?.toJson(), + }, + }; + } + + factory MostroMessage.deserialized(String data) { + try { + final decoded = jsonDecode(data); + final event = decoded as Map; + final order = event['order'] != null + ? event['order'] as Map + : throw FormatException('Missing order object'); + + final action = order['action'] != null + ? Action.fromString(order['action']) + : throw FormatException('Missing action field'); + + final id = order['id'] != null + ? order['id'] as String? + : throw FormatException('Missing id field'); + + final content = order['content'] != null ? Content.fromJson(event['order']['content']) as T : null; + + return MostroMessage( + action: action, + requestId: id, + content: content, + ); + } catch (e) { + throw FormatException('Failed to deserialize MostroMessage: $e'); + } + } +} diff --git a/lib/data/models/nostr_event.dart b/lib/data/models/nostr_event.dart index c7f56780..ab4cf4b1 100644 --- a/lib/data/models/nostr_event.dart +++ b/lib/data/models/nostr_event.dart @@ -1,149 +1,63 @@ import 'dart:convert'; +import 'package:mostro_mobile/data/models/amount.dart'; +import 'package:mostro_mobile/data/models/enums/order_type.dart'; +import 'package:mostro_mobile/data/models/rating.dart'; +import 'package:timeago/timeago.dart' as timeago; import 'package:dart_nostr/dart_nostr.dart'; -import 'package:mostro_mobile/core/utils/nostr_utils.dart'; - -class P2POrderEvent { - String id; - final String pubkey; - final int createdAt; - final int kind = 38383; // Evento P2P con tipo 38383 - final List> tags; - final String content; - String sig; - - P2POrderEvent({ - required this.id, - required this.pubkey, - required this.createdAt, - required this.tags, - required this.content, - required this.sig, - }); +import 'package:mostro_mobile/data/models/order.dart'; +extension NostrEventExtensions on NostrEvent { // Getters para acceder fácilmente a los tags específicos + String? get recipient => _getTagValue('p'); String? get orderId => _getTagValue('d'); - String? get orderType => _getTagValue('k'); + OrderType? get orderType => _getTagValue('k') != null + ? OrderType.fromString(_getTagValue('k')!) + : null; String? get currency => _getTagValue('f'); String? get status => _getTagValue('s'); String? get amount => _getTagValue('amt'); - String? get fiatAmount => _getTagValue('fa'); + Amount get fiatAmount => _getAmount('fa'); List get paymentMethods => _getTagValue('pm')?.split(',') ?? []; String? get premium => _getTagValue('premium'); + String? get source => _getTagValue('source'); + Rating? get rating => _getTagValue('rating') != null + ? Rating.deserialized(_getTagValue('rating')!) + : null; String? get network => _getTagValue('network'); String? get layer => _getTagValue('layer'); - String? get expiration => _getTagValue('expiration'); + String? get name => _getTagValue('name') ?? 'Anon'; + String? get geohash => _getTagValue('g'); + String? get bond => _getTagValue('bond'); + String? get expiration => _timeAgo(_getTagValue('expiration')); String? get platform => _getTagValue('y'); - - String? _getTagValue(String key) { - final tag = tags.firstWhere((t) => t[0] == key, orElse: () => []); - return tag.length > 1 ? tag[1] : null; - } - - // Convertir el evento a un mapa JSON - Map toJson() { - return { - "id": id, - "pubkey": pubkey, - "created_at": createdAt, - "kind": kind, - "tags": tags, - "content": content, - "sig": sig, - }; - } - - // Convertir el evento a una cadena JSON - String toJsonString() => jsonEncode(toJson()); - - // Factory para crear un evento a partir de un JSON - factory P2POrderEvent.fromJson(Map json) { - return P2POrderEvent( - id: json['id'], - pubkey: json['pubkey'], - createdAt: json['created_at'], - tags: List>.from( - json['tags'].map((tag) => List.from(tag))), - content: json['content'], - sig: json['sig'], - ); - } - - // Método para añadir o actualizar un tag - void setTag(String key, String value) { - final index = tags.indexWhere((t) => t[0] == key); - if (index != -1) { - tags[index] = [key, value]; - } else { - tags.add([key, value]); + Order? get document { + final jsonString = _getTagValue('z'); + if (jsonString != null) { + return Order.fromJson(jsonDecode(jsonString)); } + return null; } - // Método para validar el evento - bool isValid() { - // Implementa la lógica de validación aquí - // Por ejemplo, verifica que todos los tags obligatorios estén presentes - return orderId != null && - orderType != null && - currency != null && - status != null; + String? _getTagValue(String key) { + final tag = tags?.firstWhere((t) => t[0] == key, orElse: () => []); + return (tag != null && tag.length > 1) ? tag[1] : null; } - // Método para firmar el evento - void sign(String privateKey) { - final eventData = { - 'pubkey': pubkey, - 'created_at': createdAt, - 'kind': kind, - 'tags': tags, - 'content': content, - }; - - id = NostrUtils.generateId(eventData); - sig = Nostr.instance.keysService.sign(privateKey: privateKey, message: id); + Amount _getAmount(String key) { + final tag = tags?.firstWhere((t) => t[0] == key, orElse: () => []); + return (tag != null && tag.length> 1) ? Amount.fromList(tag) : Amount.empty(); } - // Método para verificar la firma del evento - bool verifySignature() { - final eventData = { - 'pubkey': pubkey, - 'created_at': createdAt, - 'kind': kind, - 'tags': tags, - 'content': content, - }; - final calculatedId = NostrUtils.generateId(eventData); - - if (calculatedId != id) { - return false; + String _timeAgo(String? ts) { + if (ts == null) return "invalid date"; + final timestamp = int.tryParse(ts); + if (timestamp != null && timestamp > 0) { + final DateTime eventTime = + DateTime.fromMillisecondsSinceEpoch(timestamp * 1000) + .subtract(Duration(hours: 36)); + return timeago.format(eventTime, allowFromNow: true); + } else { + return "invalid date"; } - - return Nostr.instance.keysService.verify( - publicKey: pubkey, - message: id, - signature: sig, - ); - } - - // Factory para crear y firmar un nuevo evento - factory P2POrderEvent.create({ - required String privateKey, - required int createdAt, - required List> tags, - required String content, - }) { - final keyPair = Nostr.instance.keysService - .generateKeyPairFromExistingPrivateKey(privateKey); - - final event = P2POrderEvent( - id: '', // Se generará al firmar - pubkey: keyPair.public, - createdAt: createdAt, - tags: tags, - content: content, - sig: '', // Se generará al firmar - ); - - event.sign(privateKey); - return event; } } diff --git a/lib/data/models/order.dart b/lib/data/models/order.dart new file mode 100644 index 00000000..5758db26 --- /dev/null +++ b/lib/data/models/order.dart @@ -0,0 +1,134 @@ +import 'package:dart_nostr/nostr/model/event/event.dart'; +import 'package:mostro_mobile/data/models/enums/order_type.dart'; +import 'package:mostro_mobile/data/models/enums/status.dart'; +import 'package:mostro_mobile/data/models/content.dart'; +import 'package:mostro_mobile/data/models/nostr_event.dart'; + +class Order implements Content { + final String? id; + final OrderType kind; + final Status status; + final int amount; + final String fiatCode; + final int? minAmount; + final int? maxAmount; + final int fiatAmount; + final String paymentMethod; + final int premium; + final String? masterBuyerPubkey; + final String? masterSellerPubkey; + final String? buyerInvoice; + final int? createdAt; + final int? expiresAt; + final int? buyerToken; + final int? sellerToken; + + Order({ + this.id, + required this.kind, + this.status = Status.pending, + this.amount = 0, + required this.fiatCode, + this.minAmount, + this.maxAmount, + required this.fiatAmount, + required this.paymentMethod, + required this.premium, + this.masterBuyerPubkey, + this.masterSellerPubkey, + this.buyerInvoice, + this.createdAt, + this.expiresAt, + this.buyerToken, + this.sellerToken, + }); + + @override + Map toJson() { + final data = { + type: { + 'kind': kind.value, + 'status': status.value, + 'amount': amount, + 'fiat_code': fiatCode, + 'fiat_amount': fiatAmount, + 'payment_method': paymentMethod, + 'premium': premium, + } + }; + + if (id != null) data[type]['id'] = id; + if (minAmount != null) data[type]['min_amount'] = minAmount; + if (maxAmount != null) data[type]['max_amount'] = maxAmount; + if (masterBuyerPubkey != null) { + data[type]['master_buyer_pubkey'] = masterBuyerPubkey; + } + if (masterSellerPubkey != null) { + data[type]['master_seller_pubkey'] = masterSellerPubkey; + } + if (buyerInvoice != null) data[type]['buyer_invoice'] = buyerInvoice; + if (createdAt != null) data[type]['created_at'] = createdAt; + if (expiresAt != null) data[type]['expires_at'] = expiresAt; + if (buyerToken != null) data[type]['buyer_token'] = buyerToken; + if (sellerToken != null) data[type]['seller_token'] = sellerToken; + + return data; + } + + factory Order.fromJson(Map json) { + // Validate required fields + void validateField(String field) { + if (!json.containsKey(field)) { + throw FormatException('Missing required field: $field'); + } + } + + // Validate required fields + ['kind', 'status', 'fiat_code', 'fiat_amount', 'payment_method', 'premium'] + .forEach(validateField); + + // Safe type casting + T? safeCast(String key, T Function(dynamic) converter) { + final value = json[key]; + return value == null ? null : converter(value); + } + + return Order( + id: safeCast('id', (v) => v.toString()), + kind: OrderType.fromString(json['kind'].toString()), + status: Status.fromString(json['status']), + amount: json['amount'], + fiatCode: json['fiat_code'], + minAmount: json['min_amount'], + maxAmount: json['max_amount'], + fiatAmount: json['fiat_amount'], + paymentMethod: json['payment_method'], + premium: json['premium'], + masterBuyerPubkey: json['master_buyer_pubkey'], + masterSellerPubkey: json['master_seller_pubkey'], + buyerInvoice: json['buyer_invoice'], + createdAt: json['created_at'], + expiresAt: json['expires_at'], + buyerToken: json['buyer_token'], + sellerToken: json['seller_token'], + ); + } + + factory Order.fromEvent(NostrEvent event) { + return Order( + id: event.orderId, + kind: event.orderType!, + status: Status.fromString(event.status!), + amount: event.amount as int, + fiatCode: event.currency!, + fiatAmount: event.fiatAmount.minimum, + paymentMethod: event.paymentMethods.join(','), + premium: event.premium as int, + createdAt: event.createdAt as int, + expiresAt: event.expiration as int?, + ); + } + + @override + String get type => 'order'; +} diff --git a/lib/data/models/order_model.dart b/lib/data/models/order_model.dart index bc192115..89aa32af 100644 --- a/lib/data/models/order_model.dart +++ b/lib/data/models/order_model.dart @@ -161,7 +161,7 @@ class OrderModel { id: getString('d'), type: getString('k'), user: name, - rating: parseRating(getString('rating')), + rating: 0, ratingCount: getInt('rating_count'), amount: getInt('amt'), currency: getString('f'), @@ -181,7 +181,6 @@ class OrderModel { buyerFiatAmount: getDouble('buyer_fiat_amount'), ); } catch (e) { - print('Error creating OrderModel from tags: $e'); throw const FormatException('Invalid tags format for OrderModel'); } } diff --git a/lib/data/models/payment_request.dart b/lib/data/models/payment_request.dart new file mode 100644 index 00000000..b4f24b34 --- /dev/null +++ b/lib/data/models/payment_request.dart @@ -0,0 +1,57 @@ +import 'package:mostro_mobile/data/models/content.dart'; +import 'package:mostro_mobile/data/models/order.dart'; + +class PaymentRequest implements Content { + final Order? order; + final String? lnInvoice; + final int? amount; + + PaymentRequest({ + this.order, + this.lnInvoice, + this.amount, + }) { + // At least one parameter should be non-null + if (order == null && lnInvoice == null && amount == null) { + throw ArgumentError('At least one parameter must be provided'); + } + } + + @override + Map toJson() { + final typeKey = type; + final List values = []; + + values.add(order?.toJson()); + values.add(lnInvoice); + + if (amount != null) { + values.add(amount); + } + + final result = {typeKey: values}; + + return result; + } + + factory PaymentRequest.fromJson(List json) { + if (json.length < 2) { + throw FormatException('Invalid JSON format: insufficient elements'); + } + final orderJson = json[0]; + final Order? order = orderJson != null ? Order.fromJson(orderJson) : null; + final lnInvoice = json[1]; + if (lnInvoice != null && lnInvoice is! String) { + throw FormatException('Invalid type for lnInvoice: expected String'); + } + final amount = json.length > 2 ? json[2] as int? : null; + return PaymentRequest( + order: order, + lnInvoice: lnInvoice as String?, + amount: amount, + ); + } + + @override + String get type => 'payment_request'; +} diff --git a/lib/data/models/peer.dart b/lib/data/models/peer.dart new file mode 100644 index 00000000..b39d01ae --- /dev/null +++ b/lib/data/models/peer.dart @@ -0,0 +1,29 @@ +import 'package:mostro_mobile/data/models/content.dart'; + +class Peer implements Content { + final String publicKey; + + Peer({required this.publicKey}); + + factory Peer.fromJson(Map json) { + final pubkey = json['pubkey']; + if (pubkey == null || pubkey is! String) { + throw FormatException('Invalid or missing pubkey in JSON'); + } + return Peer( + publicKey: pubkey, + ); + } + + @override + Map toJson() { + return { + type: { + 'pubkey': publicKey, + } + }; + } + + @override + String get type => 'Peer'; +} diff --git a/lib/data/models/rating.dart b/lib/data/models/rating.dart new file mode 100644 index 00000000..1d053377 --- /dev/null +++ b/lib/data/models/rating.dart @@ -0,0 +1,64 @@ +import 'dart:convert'; + +class Rating { + final int totalReviews; + final double totalRating; + final int lastRating; + final int maxRate; + final int minRate; + + const Rating({ + required this.totalReviews, + required this.totalRating, + required this.lastRating, + required this.maxRate, + required this.minRate, + }); + + factory Rating.deserialized(String data) { + if (data.isEmpty) { + throw FormatException('Empty data string provided'); + } + + if (data == 'none') { + return Rating.empty(); + } + + try { + final json = jsonDecode(data) as Map; + return Rating( + totalReviews: _parseInt(json, 'total_reviews'), + totalRating: _parseDouble(json, 'total_rating'), + lastRating: _parseInt(json, 'last_rating'), + maxRate: _parseInt(json, 'max_rate'), + minRate: _parseInt(json, 'min_rate'), + ); + } catch (e) { + throw FormatException('Failed to parse rating data: $e'); + } + } + + static int _parseInt(Map json, String field) { + final value = json[field]; + if (value is int) return value; + if (value is double) return value.toInt(); + throw FormatException('Invalid value for $field: $value'); + } + + static double _parseDouble(Map json, String field) { + final value = json[field]; + if (value is double) return value; + if (value is int) return value.toDouble(); + throw FormatException('Invalid value for $field: $value'); + } + + static Rating empty() { + return const Rating( + totalReviews: 0, + totalRating: 0.0, + lastRating: 0, + maxRate: 0, + minRate: 0, + ); + } +} \ No newline at end of file diff --git a/lib/data/models/session.dart b/lib/data/models/session.dart new file mode 100644 index 00000000..ed004033 --- /dev/null +++ b/lib/data/models/session.dart @@ -0,0 +1,40 @@ +import 'package:dart_nostr/dart_nostr.dart'; + +/// Represents a User session +/// +/// This class is used to store details of a user session +/// which is connected to a single Buy/Sell order and is +/// persisted for a maximum of 48hrs or until the order is +/// completed +class Session { + final String sessionId; + final DateTime startTime; + final NostrKeyPairs keyPair; + String? eventId; + + Session({ + required this.sessionId, + required this.startTime, + required this.keyPair, + this.eventId, + }); + + Map toJson() => { + 'sessionId': sessionId, + 'startTime': startTime.toIso8601String(), + 'privateKey': keyPair.private, + 'eventId' : eventId, + }; + + factory Session.fromJson(Map json) { + return Session( + sessionId: json['sessionId'], + startTime: DateTime.parse(json['startTime']), + keyPair: NostrKeyPairs(private: json['privateKey']), + eventId: json['eventId'], + ); + } + + String get privateKey => keyPair.private; + String get publicKey => keyPair.public; +} diff --git a/lib/data/models/text_message.dart b/lib/data/models/text_message.dart new file mode 100644 index 00000000..8ce7ae0a --- /dev/null +++ b/lib/data/models/text_message.dart @@ -0,0 +1,17 @@ +import 'package:mostro_mobile/data/models/content.dart'; + +class TextMessage implements Content { + final String message; + + TextMessage({required this.message}); + + @override + Map toJson() { + return { + type: message, + }; + } + + @override + String get type => 'text_message'; +} diff --git a/lib/data/models/user_model.dart b/lib/data/models/user_model.dart deleted file mode 100644 index bc59cc33..00000000 --- a/lib/data/models/user_model.dart +++ /dev/null @@ -1,17 +0,0 @@ -class User { - final String publicKey; - - User({required this.publicKey}); - - factory User.fromJson(Map json) { - return User( - publicKey: json['publicKey'] as String, - ); - } - - Map toJson() { - return { - 'publicKey': publicKey, - }; - } -} diff --git a/lib/data/repositories/mostro_order_repository.dart b/lib/data/repositories/mostro_order_repository.dart deleted file mode 100644 index bd3f4006..00000000 --- a/lib/data/repositories/mostro_order_repository.dart +++ /dev/null @@ -1,44 +0,0 @@ -import 'package:mostro_mobile/data/models/order_model.dart'; -import 'package:mostro_mobile/data/repositories/order_repository_interface.dart'; -import 'package:mostro_mobile/services/mostro_service.dart'; - -class MostroOrderRepository implements OrderRepository { - final MostroService _mostroService; - - MostroOrderRepository(this._mostroService); - - @override - Future createOrder(OrderModel order) async { - await _mostroService.publishOrder(order); - } - - @override - Future cancelOrder(String orderId) async { - await _mostroService.cancelOrder(orderId); - } - - @override - Future takeSellOrder(String orderId, {int? amount}) async { - await _mostroService.takeSellOrder(orderId, amount: amount); - } - - @override - Future takeBuyOrder(String orderId, {int? amount}) async { - await _mostroService.takeBuyOrder(orderId, amount: amount); - } - - @override - Stream getPendingOrders() { - return _mostroService.subscribeToOrders().where((order) => order.status == 'pending'); - } - - @override - Future sendFiatSent(String orderId) async { - await _mostroService.sendFiatSent(orderId); - } - - @override - Future releaseOrder(String orderId) async { - await _mostroService.releaseOrder(orderId); - } -} \ No newline at end of file diff --git a/lib/data/repositories/mostro_repository.dart b/lib/data/repositories/mostro_repository.dart new file mode 100644 index 00000000..b9fb4e1c --- /dev/null +++ b/lib/data/repositories/mostro_repository.dart @@ -0,0 +1,39 @@ +import 'dart:async'; +import 'package:dart_nostr/nostr/model/request/filter.dart'; +import 'package:mostro_mobile/data/models/mostro_message.dart'; +import 'package:mostro_mobile/data/repositories/order_repository_interface.dart'; + +class MostroRepository implements OrderRepository { + + // final NostrService _nostrService; + + final StreamController> _eventStreamController = + StreamController.broadcast(); + + final Map _orders = {}; + //final SecureStorageManager _secureStorageManager; + + MostroRepository(); + + void subscribe(NostrFilter filter) { + //_nostrService.subscribeToEvents(filter).listen((event) async { + + // final recipient = event.recipient; + + // final session = await _secureStorageManager.loadSession(recipient!); + + // final form = await decryptMessage( + // event.content!, session!.privateKey, event.pubkey); + + //final message = MostroMessage.deserialized(form); + //_orders[message.requestId!] = message; + //_eventStreamController.add(_orders.values.toList()); + //}); + } + + MostroMessage? getOrder(String orderId) { + return _orders[orderId]; + } + + +} diff --git a/lib/data/repositories/open_orders_repository.dart b/lib/data/repositories/open_orders_repository.dart new file mode 100644 index 00000000..866007d9 --- /dev/null +++ b/lib/data/repositories/open_orders_repository.dart @@ -0,0 +1,45 @@ +import 'dart:async'; +import 'package:dart_nostr/nostr/model/event/event.dart'; +import 'package:dart_nostr/nostr/model/request/filter.dart'; +import 'package:mostro_mobile/data/models/nostr_event.dart'; +import 'package:mostro_mobile/data/repositories/order_repository_interface.dart'; +import 'package:mostro_mobile/services/nostr_service.dart'; + +class OpenOrdersRepository implements OrderRepository { + final NostrService _nostrService; + final StreamController> _eventStreamController = + StreamController.broadcast(); + final Map _events = {}; + + Stream> get eventsStream => _eventStreamController.stream; + + OpenOrdersRepository(this._nostrService); + + StreamSubscription? _subscription; + + /// Subscribes to events matching the given filter. + /// + /// @param filter The filter criteria for events. + /// @throws ArgumentError if filter is null + void subscribe(NostrFilter filter) { + ArgumentError.checkNotNull(filter, 'filter'); + + // Cancel existing subscription if any + _subscription?.cancel(); + + _subscription = _nostrService.subscribeToEvents(filter).listen((event) { + final key = '${event.kind}-${event.pubkey}-${event.orderId}'; + _events[key] = event; + _eventStreamController.add(_events.values.toList()); + }, onError: (error) { + // Log error and optionally notify listeners + print('Error in order subscription: $error'); + }); + } + + void dispose() { + _subscription?.cancel(); + _eventStreamController.close(); + _events.clear(); + } +} diff --git a/lib/data/repositories/order_repository_interface.dart b/lib/data/repositories/order_repository_interface.dart index 9ccf4a5f..cb38e3b5 100644 --- a/lib/data/repositories/order_repository_interface.dart +++ b/lib/data/repositories/order_repository_interface.dart @@ -1,17 +1,3 @@ -import 'package:mostro_mobile/data/models/order_model.dart'; - abstract class OrderRepository { - Future createOrder(OrderModel order); - - Future cancelOrder(String orderId); - - Future takeSellOrder(String orderId, {int? amount}); - - Future takeBuyOrder(String orderId, {int? amount}); - - Stream getPendingOrders(); - - Future sendFiatSent(String orderId); - Future releaseOrder(String orderId); } diff --git a/lib/data/repositories/secure_storage_manager.dart b/lib/data/repositories/secure_storage_manager.dart new file mode 100644 index 00000000..53101118 --- /dev/null +++ b/lib/data/repositories/secure_storage_manager.dart @@ -0,0 +1,90 @@ +import 'dart:async'; +import 'dart:convert'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:mostro_mobile/data/models/session.dart'; +import 'package:mostro_mobile/core/utils/nostr_utils.dart'; + +class SecureStorageManager { + Timer? _cleanupTimer; + final int sessionExpirationHours = 48; + static const cleanupIntervalMinutes = 30; + static const maxBatchSize = 100; + + SecureStorageManager() { + _initializeCleanup(); + } + + Future newSession() async { + final keys = NostrUtils.generateKeyPair(); + final session = Session( + sessionId: keys.public, + startTime: DateTime.now(), + keyPair: keys, + ); + await saveSession(session); + return session; + } + + Future saveSession(Session session) async { + final prefs = await SharedPreferences.getInstance(); + String sessionJson = jsonEncode(session.toJson()); + await prefs.setString(session.sessionId, sessionJson); + } + + Future loadSession(String sessionId) async { + final prefs = await SharedPreferences.getInstance(); + String? sessionJson = prefs.getString(sessionId); + if (sessionJson != null) { + return Session.fromJson(jsonDecode(sessionJson)); + } + return null; + } + + Future deleteSession(String sessionId) async { + final prefs = await SharedPreferences.getInstance(); + await prefs.remove(sessionId); + } + + Future clearExpiredSessions() async { + try { + final prefs = await SharedPreferences.getInstance(); + final now = DateTime.now(); + final allKeys = prefs.getKeys(); + int processedCount = 0; + + for (var key in allKeys) { + if (processedCount >= maxBatchSize) { + // Schedule remaining cleanup for next run + break; + } + final sessionJson = prefs.getString(key); + if (sessionJson != null) { + try { + final session = Session.fromJson(jsonDecode(sessionJson)); + if (now.difference(session.startTime).inHours >= sessionExpirationHours) { + await prefs.remove(key); + processedCount++; + } + } catch (e) { + print('Error processing session $key: $e'); + await prefs.remove(key); + processedCount++; + } + } + } + } catch (e) { + print('Error during session cleanup: $e'); + } + } + + void _initializeCleanup() { + clearExpiredSessions(); + _cleanupTimer = Timer.periodic(Duration(minutes: cleanupIntervalMinutes), (timer) { + clearExpiredSessions(); + }); + } + + void dispose() { + _cleanupTimer?.cancel(); + } +} diff --git a/lib/generated/intl/messages_all.dart b/lib/generated/intl/messages_all.dart new file mode 100644 index 00000000..203415cc --- /dev/null +++ b/lib/generated/intl/messages_all.dart @@ -0,0 +1,63 @@ +// DO NOT EDIT. This is code generated via package:intl/generate_localized.dart +// This is a library that looks up messages for specific locales by +// delegating to the appropriate library. + +// Ignore issues from commonly used lints in this file. +// ignore_for_file:implementation_imports, file_names, unnecessary_new +// ignore_for_file:unnecessary_brace_in_string_interps, directives_ordering +// ignore_for_file:argument_type_not_assignable, invalid_assignment +// ignore_for_file:prefer_single_quotes, prefer_generic_function_type_aliases +// ignore_for_file:comment_references + +import 'dart:async'; + +import 'package:flutter/foundation.dart'; +import 'package:intl/intl.dart'; +import 'package:intl/message_lookup_by_library.dart'; +import 'package:intl/src/intl_helpers.dart'; + +import 'messages_en.dart' as messages_en; + +typedef Future LibraryLoader(); +Map _deferredLibraries = { + 'en': () => new SynchronousFuture(null), +}; + +MessageLookupByLibrary? _findExact(String localeName) { + switch (localeName) { + case 'en': + return messages_en.messages; + default: + return null; + } +} + +/// User programs should call this before using [localeName] for messages. +Future initializeMessages(String localeName) { + var availableLocale = Intl.verifiedLocale( + localeName, (locale) => _deferredLibraries[locale] != null, + onFailure: (_) => null); + if (availableLocale == null) { + return new SynchronousFuture(false); + } + var lib = _deferredLibraries[availableLocale]; + lib == null ? new SynchronousFuture(false) : lib(); + initializeInternalMessageLookup(() => new CompositeMessageLookup()); + messageLookup.addLocale(availableLocale, _findGeneratedMessagesFor); + return new SynchronousFuture(true); +} + +bool _messagesExistFor(String locale) { + try { + return _findExact(locale) != null; + } catch (e) { + return false; + } +} + +MessageLookupByLibrary? _findGeneratedMessagesFor(String locale) { + var actualLocale = + Intl.verifiedLocale(locale, _messagesExistFor, onFailure: (_) => null); + if (actualLocale == null) return null; + return _findExact(actualLocale); +} diff --git a/lib/generated/intl/messages_en.dart b/lib/generated/intl/messages_en.dart new file mode 100644 index 00000000..2dda54e3 --- /dev/null +++ b/lib/generated/intl/messages_en.dart @@ -0,0 +1,163 @@ +// DO NOT EDIT. This is code generated via package:intl/generate_localized.dart +// This is a library that provides messages for a en locale. All the +// messages from the main program should be duplicated here with the same +// function name. + +// Ignore issues from commonly used lints in this file. +// ignore_for_file:unnecessary_brace_in_string_interps, unnecessary_new +// ignore_for_file:prefer_single_quotes,comment_references, directives_ordering +// ignore_for_file:annotate_overrides,prefer_generic_function_type_aliases +// ignore_for_file:unused_import, file_names, avoid_escaping_inner_quotes +// ignore_for_file:unnecessary_string_interpolations, unnecessary_string_escapes + +import 'package:intl/intl.dart'; +import 'package:intl/message_lookup_by_library.dart'; + +final messages = new MessageLookup(); + +typedef String MessageIfAbsent(String messageStr, List args); + +class MessageLookup extends MessageLookupByLibrary { + String get localeName => 'en'; + + static String m0(amount, fiat_code, fiat_amount, expiration_seconds) => + "Please send me an invoice for ${amount} satoshis equivalent to ${fiat_code} ${fiat_amount}. This is where I\'ll send the funds upon completion of the trade. If you don\'t provide the invoice within ${expiration_seconds} this trade will be cancelled."; + + static String m1(npub) => "You have successfully added the solver ${npub}."; + + static String m2(id) => "You have cancelled the order ID: ${id}!"; + + static String m3(id) => "Admin has cancelled the order ID: ${id}!"; + + static String m4(id) => "You have completed the order ID: ${id}!"; + + static String m5(id) => "Admin has completed the order ID: ${id}!"; + + static String m6(details) => + "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."; + + static String m7(admin_npub) => + "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."; + + static String m8(buyer_npub, fiat_code, fiat_amount, payment_method) => + "Get in touch with the buyer, this is their npub ${buyer_npub} to inform them how to send you ${fiat_code} ${fiat_amount} through ${payment_method}. I will notify you once the buyer indicates the fiat money has been sent. Afterward, you should verify if it has arrived. If the buyer does not respond, you can initiate a cancellation or a dispute. Remember, an administrator will NEVER contact you to resolve your order unless you open a dispute first."; + + static String m9(id) => "You have cancelled the order ID: ${id}!"; + + static String m10(action) => + "You are not allowed to ${action} for this order!"; + + static String m11(id) => "Order ${id} has been successfully cancelled!"; + + static String m12(id) => + "Your counterparty wants to cancel order ID: ${id}. Note that no administrator will contact you regarding this cancellation unless you open a dispute first. If you agree on such cancellation, please send me cancel-order-message."; + + static String m13(id) => + "You have initiated the cancellation of the order ID: ${id}. Your counterparty must agree to the cancellation too. 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."; + + static String m14(id, user_token) => + "Your counterparty has initiated a dispute for order Id: ${id}. A solver will be assigned to your dispute soon. Once assigned, I will share their npub with you, and only they will be able to assist you. You may contact the solver directly, but if they reach out first, please ask them to provide the token for your dispute. Your dispute token is: ${user_token}."; + + static String m15(id, user_token) => + "You have initiated a dispute for order Id: ${id}. A solver will be assigned to your dispute soon. Once assigned, I will share their npub with you, and only they will be able to assist you. You may contact the solver directly, but if they reach out first, please ask them to provide the token for your dispute. Your dispute token is: ${user_token}."; + + static String m16(seller_npub) => + "I have informed ${seller_npub} that you have sent the fiat money. When the seller confirms they have received your fiat money, they should release the funds. If they refuse, you can open a dispute."; + + static String m17(buyer_npub) => + "${buyer_npub} has informed that they have sent you the fiat money. Once you confirm receipt, please release the funds. After releasing, the money will go to the buyer and there will be no turning back, so only proceed if you are sure. If you want to release the Sats to the buyer, send me release-order-message."; + + static String m18(seller_npub, id, fiat_code, fiat_amount, payment_method) => + "Get in touch with the seller, this is their npub ${seller_npub} to get the details on how to send the fiat money for the order ${id}, you must send ${fiat_code} ${fiat_amount} using ${payment_method}. Once you send the fiat money, please let me know with fiat-sent."; + + static String m19(buyer_npub) => + "Your Sats sale has been completed after confirming the payment from ${buyer_npub}."; + + static String m20(amount) => + "The amount stated in the invoice is incorrect. Please send an invoice with an amount of ${amount} satoshis, an invoice without an amount, or a lightning address."; + + static String m21(action) => + "You did not create this order and are not authorized to ${action} it."; + + static String m22(expiration_hours) => + "Your offer has been published! Please wait until another user picks your order. It will be available for ${expiration_hours} hours. You can cancel this order before another user picks it up by executing: cancel."; + + static String m23(action, id, order_status) => + "You are not allowed to ${action} because order Id ${id} status is ${order_status}."; + + static String m24(min_amount, max_amount) => + "The requested amount is incorrect and may be outside the acceptable range. The minimum is ${min_amount} and the maximum is ${max_amount}."; + + static String m25(min_order_amount, max_order_amount) => + "The allowed Sats amount for this Mostro is between min ${min_order_amount} and max ${max_order_amount}. Please enter an amount within this range."; + + static String m26(amount, fiat_code, fiat_amount, expiration_seconds) => + "Please pay this hold invoice of ${amount} Sats for ${fiat_code} ${fiat_amount} to start the operation. If you do not pay it within ${expiration_seconds} the trade will be cancelled."; + + static String m27(payment_attempts, payment_retries_interval) => + "I tried to send you the Sats but the payment of your invoice failed. I will try ${payment_attempts} more times in ${payment_retries_interval} minutes window. Please ensure your node/wallet is online."; + + static String m28(seller_npub) => + "${seller_npub} has already released the Sats! Expect your invoice to be paid any time. Remember your wallet needs to be online to receive through the Lightning Network."; + + static String m29(expiration_seconds) => + "Payment received! Your Sats are now \'held\' in your own wallet. Please wait a bit. I\'ve requested the buyer to provide an invoice. Once they do, I\'ll connect you both. If they do not do so within ${expiration_seconds} your Sats will be available in your wallet again and the trade will be cancelled."; + + static String m30(id, expiration_seconds) => + "Please wait a bit. I\'ve sent a payment request to the seller to send the Sats for the order ID ${id}. Once the payment is made, I\'ll connect you both. If the seller doesn\'t complete the payment within ${expiration_seconds} minutes the trade will be cancelled."; + + final messages = _notInlinedMessages(_notInlinedMessages); + static Map _notInlinedMessages(_) => { + "add_invoice": m0, + "admin_add_solver": m1, + "admin_canceled_admin": m2, + "admin_canceled_users": m3, + "admin_settled_admin": m4, + "admin_settled_users": m5, + "admin_took_dispute_admin": m6, + "admin_took_dispute_users": m7, + "buyer_invoice_accepted": MessageLookupByLibrary.simpleMessage( + "Invoice has been successfully saved!"), + "buyer_took_order": m8, + "canceled": m9, + "cant_do": m10, + "cooperative_cancel_accepted": m11, + "cooperative_cancel_initiated_by_peer": m12, + "cooperative_cancel_initiated_by_you": m13, + "dispute_initiated_by_peer": m14, + "dispute_initiated_by_you": m15, + "fiat_sent_ok_buyer": m16, + "fiat_sent_ok_seller": m17, + "hold_invoice_payment_accepted": m18, + "hold_invoice_payment_canceled": MessageLookupByLibrary.simpleMessage( + "The invoice was cancelled; your Sats will be available in your wallet again."), + "hold_invoice_payment_settled": m19, + "incorrect_invoice_amount_buyer_add_invoice": m20, + "incorrect_invoice_amount_buyer_new_order": + MessageLookupByLibrary.simpleMessage( + "An invoice with non-zero amount was received for the new order. Please send an invoice with a zero amount or no invoice at all."), + "invalid_sats_amount": MessageLookupByLibrary.simpleMessage( + "The specified Sats amount is invalid."), + "invoice_updated": MessageLookupByLibrary.simpleMessage( + "Invoice has been successfully updated!"), + "is_not_your_dispute": MessageLookupByLibrary.simpleMessage( + "This dispute was not assigned to you!"), + "is_not_your_order": m21, + "new_order": m22, + "not_allowed_by_status": m23, + "not_found": MessageLookupByLibrary.simpleMessage("Dispute not found."), + "out_of_range_fiat_amount": m24, + "out_of_range_sats_amount": m25, + "pay_invoice": m26, + "payment_failed": m27, + "purchase_completed": MessageLookupByLibrary.simpleMessage( + "Your satoshis purchase has been completed successfully. I have paid your invoice, enjoy sound money!"), + "rate": MessageLookupByLibrary.simpleMessage( + "Please rate your counterparty"), + "rate_received": + MessageLookupByLibrary.simpleMessage("Rating successfully saved!"), + "released": m28, + "waiting_buyer_invoice": m29, + "waiting_seller_to_pay": m30 + }; +} diff --git a/lib/generated/l10n.dart b/lib/generated/l10n.dart new file mode 100644 index 00000000..10d8c509 --- /dev/null +++ b/lib/generated/l10n.dart @@ -0,0 +1,494 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND +import 'package:flutter/material.dart'; +import 'package:intl/intl.dart'; +import 'intl/messages_all.dart'; + +// ************************************************************************** +// Generator: Flutter Intl IDE plugin +// Made by Localizely +// ************************************************************************** + +// ignore_for_file: non_constant_identifier_names, lines_longer_than_80_chars +// ignore_for_file: join_return_with_assignment, prefer_final_in_for_each +// ignore_for_file: avoid_redundant_argument_values, avoid_escaping_inner_quotes + +class S { + S(); + + static S? _current; + + static S get current { + assert(_current != null, + 'No instance of S was loaded. Try to initialize the S delegate before accessing S.current.'); + return _current!; + } + + static const AppLocalizationDelegate delegate = AppLocalizationDelegate(); + + static Future load(Locale locale) { + final name = (locale.countryCode?.isEmpty ?? false) + ? locale.languageCode + : locale.toString(); + final localeName = Intl.canonicalizedLocale(name); + return initializeMessages(localeName).then((_) { + Intl.defaultLocale = localeName; + final instance = S(); + S._current = instance; + + return instance; + }); + } + + static S of(BuildContext context) { + final instance = S.maybeOf(context); + assert(instance != null, + 'No instance of S present in the widget tree. Did you add S.delegate in localizationsDelegates?'); + return instance!; + } + + static S? maybeOf(BuildContext context) { + return Localizations.of(context, S); + } + + /// `Your offer has been published! Please wait until another user picks your order. It will be available for {expiration_hours} hours. You can cancel this order before another user picks it up by executing: cancel.` + String new_order(Object expiration_hours) { + return Intl.message( + 'Your offer has been published! Please wait until another user picks your order. It will be available for $expiration_hours hours. You can cancel this order before another user picks it up by executing: cancel.', + name: 'new_order', + desc: '', + args: [expiration_hours], + ); + } + + /// `You have cancelled the order ID: {id}!` + String canceled(Object id) { + return Intl.message( + 'You have cancelled the order ID: $id!', + name: 'canceled', + desc: '', + args: [id], + ); + } + + /// `Please pay this hold invoice of {amount} Sats for {fiat_code} {fiat_amount} to start the operation. If you do not pay it within {expiration_seconds} the trade will be cancelled.` + String pay_invoice(Object amount, Object fiat_code, Object fiat_amount, + Object expiration_seconds) { + return Intl.message( + 'Please pay this hold invoice of $amount Sats for $fiat_code $fiat_amount to start the operation. If you do not pay it within $expiration_seconds the trade will be cancelled.', + name: 'pay_invoice', + desc: '', + args: [amount, fiat_code, fiat_amount, expiration_seconds], + ); + } + + /// `Please send me an invoice for {amount} satoshis equivalent to {fiat_code} {fiat_amount}. This is where I'll send the funds upon completion of the trade. If you don't provide the invoice within {expiration_seconds} this trade will be cancelled.` + String add_invoice(Object amount, Object fiat_code, Object fiat_amount, + Object expiration_seconds) { + return Intl.message( + 'Please send me an invoice for $amount satoshis equivalent to $fiat_code $fiat_amount. This is where I\'ll send the funds upon completion of the trade. If you don\'t provide the invoice within $expiration_seconds this trade will be cancelled.', + name: 'add_invoice', + desc: '', + args: [amount, fiat_code, fiat_amount, expiration_seconds], + ); + } + + /// `Please wait a bit. I've sent a payment request to the seller to send the Sats for the order ID {id}. Once the payment is made, I'll connect you both. If the seller doesn't complete the payment within {expiration_seconds} minutes the trade will be cancelled.` + String waiting_seller_to_pay(Object id, Object expiration_seconds) { + return Intl.message( + 'Please wait a bit. I\'ve sent a payment request to the seller to send the Sats for the order ID $id. Once the payment is made, I\'ll connect you both. If the seller doesn\'t complete the payment within $expiration_seconds minutes the trade will be cancelled.', + name: 'waiting_seller_to_pay', + desc: '', + args: [id, expiration_seconds], + ); + } + + /// `Payment received! Your Sats are now 'held' in your own wallet. Please wait a bit. I've requested the buyer to provide an invoice. Once they do, I'll connect you both. If they do not do so within {expiration_seconds} your Sats will be available in your wallet again and the trade will be cancelled.` + String waiting_buyer_invoice(Object expiration_seconds) { + return Intl.message( + 'Payment received! Your Sats are now \'held\' in your own wallet. Please wait a bit. I\'ve requested the buyer to provide an invoice. Once they do, I\'ll connect you both. If they do not do so within $expiration_seconds your Sats will be available in your wallet again and the trade will be cancelled.', + name: 'waiting_buyer_invoice', + desc: '', + args: [expiration_seconds], + ); + } + + /// `Invoice has been successfully saved!` + String get buyer_invoice_accepted { + return Intl.message( + 'Invoice has been successfully saved!', + name: 'buyer_invoice_accepted', + desc: '', + args: [], + ); + } + + /// `Get in touch with the seller, this is their npub {seller_npub} to get the details on how to send the fiat money for the order {id}, you must send {fiat_code} {fiat_amount} using {payment_method}. Once you send the fiat money, please let me know with fiat-sent.` + String hold_invoice_payment_accepted(Object seller_npub, Object id, + Object fiat_code, Object fiat_amount, Object payment_method) { + return Intl.message( + 'Get in touch with the seller, this is their npub $seller_npub to get the details on how to send the fiat money for the order $id, you must send $fiat_code $fiat_amount using $payment_method. Once you send the fiat money, please let me know with fiat-sent.', + name: 'hold_invoice_payment_accepted', + desc: '', + args: [seller_npub, id, fiat_code, fiat_amount, payment_method], + ); + } + + /// `Get in touch with the buyer, this is their npub {buyer_npub} to inform them how to send you {fiat_code} {fiat_amount} through {payment_method}. I will notify you once the buyer indicates the fiat money has been sent. Afterward, you should verify if it has arrived. If the buyer does not respond, you can initiate a cancellation or a dispute. Remember, an administrator will NEVER contact you to resolve your order unless you open a dispute first.` + String buyer_took_order(Object buyer_npub, Object fiat_code, + Object fiat_amount, Object payment_method) { + return Intl.message( + 'Get in touch with the buyer, this is their npub $buyer_npub to inform them how to send you $fiat_code $fiat_amount through $payment_method. I will notify you once the buyer indicates the fiat money has been sent. Afterward, you should verify if it has arrived. If the buyer does not respond, you can initiate a cancellation or a dispute. Remember, an administrator will NEVER contact you to resolve your order unless you open a dispute first.', + name: 'buyer_took_order', + desc: '', + args: [buyer_npub, fiat_code, fiat_amount, payment_method], + ); + } + + /// `I have informed {seller_npub} that you have sent the fiat money. When the seller confirms they have received your fiat money, they should release the funds. If they refuse, you can open a dispute.` + String fiat_sent_ok_buyer(Object seller_npub) { + return Intl.message( + 'I have informed $seller_npub that you have sent the fiat money. When the seller confirms they have received your fiat money, they should release the funds. If they refuse, you can open a dispute.', + name: 'fiat_sent_ok_buyer', + desc: '', + args: [seller_npub], + ); + } + + /// `{buyer_npub} has informed that they have sent you the fiat money. Once you confirm receipt, please release the funds. After releasing, the money will go to the buyer and there will be no turning back, so only proceed if you are sure. If you want to release the Sats to the buyer, send me release-order-message.` + String fiat_sent_ok_seller(Object buyer_npub) { + return Intl.message( + '$buyer_npub has informed that they have sent you the fiat money. Once you confirm receipt, please release the funds. After releasing, the money will go to the buyer and there will be no turning back, so only proceed if you are sure. If you want to release the Sats to the buyer, send me release-order-message.', + name: 'fiat_sent_ok_seller', + desc: '', + args: [buyer_npub], + ); + } + + /// `{seller_npub} has already released the Sats! Expect your invoice to be paid any time. Remember your wallet needs to be online to receive through the Lightning Network.` + String released(Object seller_npub) { + return Intl.message( + '$seller_npub has already released the Sats! Expect your invoice to be paid any time. Remember your wallet needs to be online to receive through the Lightning Network.', + name: 'released', + desc: '', + args: [seller_npub], + ); + } + + /// `Your satoshis purchase has been completed successfully. I have paid your invoice, enjoy sound money!` + String get purchase_completed { + return Intl.message( + 'Your satoshis purchase has been completed successfully. I have paid your invoice, enjoy sound money!', + name: 'purchase_completed', + desc: '', + args: [], + ); + } + + /// `Your Sats sale has been completed after confirming the payment from {buyer_npub}.` + String hold_invoice_payment_settled(Object buyer_npub) { + return Intl.message( + 'Your Sats sale has been completed after confirming the payment from $buyer_npub.', + name: 'hold_invoice_payment_settled', + desc: '', + args: [buyer_npub], + ); + } + + /// `Please rate your counterparty` + String get rate { + return Intl.message( + 'Please rate your counterparty', + name: 'rate', + desc: '', + args: [], + ); + } + + /// `Rating successfully saved!` + String get rate_received { + return Intl.message( + 'Rating successfully saved!', + name: 'rate_received', + desc: '', + args: [], + ); + } + + /// `You have initiated the cancellation of the order ID: {id}. Your counterparty must agree to the cancellation too. 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.` + String cooperative_cancel_initiated_by_you(Object id) { + return Intl.message( + 'You have initiated the cancellation of the order ID: $id. Your counterparty must agree to the cancellation too. 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.', + name: 'cooperative_cancel_initiated_by_you', + desc: '', + args: [id], + ); + } + + /// `Your counterparty wants to cancel order ID: {id}. Note that no administrator will contact you regarding this cancellation unless you open a dispute first. If you agree on such cancellation, please send me cancel-order-message.` + String cooperative_cancel_initiated_by_peer(Object id) { + return Intl.message( + 'Your counterparty wants to cancel order ID: $id. Note that no administrator will contact you regarding this cancellation unless you open a dispute first. If you agree on such cancellation, please send me cancel-order-message.', + name: 'cooperative_cancel_initiated_by_peer', + desc: '', + args: [id], + ); + } + + /// `Order {id} has been successfully cancelled!` + String cooperative_cancel_accepted(Object id) { + return Intl.message( + 'Order $id has been successfully cancelled!', + name: 'cooperative_cancel_accepted', + desc: '', + args: [id], + ); + } + + /// `You have initiated a dispute for order Id: {id}. A solver will be assigned to your dispute soon. Once assigned, I will share their npub with you, and only they will be able to assist you. You may contact the solver directly, but if they reach out first, please ask them to provide the token for your dispute. Your dispute token is: {user_token}.` + String dispute_initiated_by_you(Object id, Object user_token) { + return Intl.message( + 'You have initiated a dispute for order Id: $id. A solver will be assigned to your dispute soon. Once assigned, I will share their npub with you, and only they will be able to assist you. You may contact the solver directly, but if they reach out first, please ask them to provide the token for your dispute. Your dispute token is: $user_token.', + name: 'dispute_initiated_by_you', + desc: '', + args: [id, user_token], + ); + } + + /// `Your counterparty has initiated a dispute for order Id: {id}. A solver will be assigned to your dispute soon. Once assigned, I will share their npub with you, and only they will be able to assist you. You may contact the solver directly, but if they reach out first, please ask them to provide the token for your dispute. Your dispute token is: {user_token}.` + String dispute_initiated_by_peer(Object id, Object user_token) { + return Intl.message( + 'Your counterparty has initiated a dispute for order Id: $id. A solver will be assigned to your dispute soon. Once assigned, I will share their npub with you, and only they will be able to assist you. You may contact the solver directly, but if they reach out first, please ask them to provide the token for your dispute. Your dispute token is: $user_token.', + name: 'dispute_initiated_by_peer', + desc: '', + args: [id, user_token], + ); + } + + /// `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.` + String admin_took_dispute_admin(Object details) { + return Intl.message( + '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.', + name: 'admin_took_dispute_admin', + desc: '', + args: [details], + ); + } + + /// `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.` + String admin_took_dispute_users(Object admin_npub) { + return Intl.message( + '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.', + name: 'admin_took_dispute_users', + desc: '', + args: [admin_npub], + ); + } + + /// `You have cancelled the order ID: {id}!` + String admin_canceled_admin(Object id) { + return Intl.message( + 'You have cancelled the order ID: $id!', + name: 'admin_canceled_admin', + desc: '', + args: [id], + ); + } + + /// `Admin has cancelled the order ID: {id}!` + String admin_canceled_users(Object id) { + return Intl.message( + 'Admin has cancelled the order ID: $id!', + name: 'admin_canceled_users', + desc: '', + args: [id], + ); + } + + /// `You have completed the order ID: {id}!` + String admin_settled_admin(Object id) { + return Intl.message( + 'You have completed the order ID: $id!', + name: 'admin_settled_admin', + desc: '', + args: [id], + ); + } + + /// `Admin has completed the order ID: {id}!` + String admin_settled_users(Object id) { + return Intl.message( + 'Admin has completed the order ID: $id!', + name: 'admin_settled_users', + desc: '', + args: [id], + ); + } + + /// `This dispute was not assigned to you!` + String get is_not_your_dispute { + return Intl.message( + 'This dispute was not assigned to you!', + name: 'is_not_your_dispute', + desc: '', + args: [], + ); + } + + /// `Dispute not found.` + String get not_found { + return Intl.message( + 'Dispute not found.', + name: 'not_found', + desc: '', + args: [], + ); + } + + /// `I tried to send you the Sats but the payment of your invoice failed. I will try {payment_attempts} more times in {payment_retries_interval} minutes window. Please ensure your node/wallet is online.` + String payment_failed( + Object payment_attempts, Object payment_retries_interval) { + return Intl.message( + 'I tried to send you the Sats but the payment of your invoice failed. I will try $payment_attempts more times in $payment_retries_interval minutes window. Please ensure your node/wallet is online.', + name: 'payment_failed', + desc: '', + args: [payment_attempts, payment_retries_interval], + ); + } + + /// `Invoice has been successfully updated!` + String get invoice_updated { + return Intl.message( + 'Invoice has been successfully updated!', + name: 'invoice_updated', + desc: '', + args: [], + ); + } + + /// `The invoice was cancelled; your Sats will be available in your wallet again.` + String get hold_invoice_payment_canceled { + return Intl.message( + 'The invoice was cancelled; your Sats will be available in your wallet again.', + name: 'hold_invoice_payment_canceled', + desc: '', + args: [], + ); + } + + /// `You are not allowed to {action} for this order!` + String cant_do(Object action) { + return Intl.message( + 'You are not allowed to $action for this order!', + name: 'cant_do', + desc: '', + args: [action], + ); + } + + /// `You have successfully added the solver {npub}.` + String admin_add_solver(Object npub) { + return Intl.message( + 'You have successfully added the solver $npub.', + name: 'admin_add_solver', + desc: '', + args: [npub], + ); + } + + /// `You did not create this order and are not authorized to {action} it.` + String is_not_your_order(Object action) { + return Intl.message( + 'You did not create this order and are not authorized to $action it.', + name: 'is_not_your_order', + desc: '', + args: [action], + ); + } + + /// `You are not allowed to {action} because order Id {id} status is {order_status}.` + String not_allowed_by_status(Object action, Object id, Object order_status) { + return Intl.message( + 'You are not allowed to $action because order Id $id status is $order_status.', + name: 'not_allowed_by_status', + desc: '', + args: [action, id, order_status], + ); + } + + /// `The requested amount is incorrect and may be outside the acceptable range. The minimum is {min_amount} and the maximum is {max_amount}.` + String out_of_range_fiat_amount(Object min_amount, Object max_amount) { + return Intl.message( + 'The requested amount is incorrect and may be outside the acceptable range. The minimum is $min_amount and the maximum is $max_amount.', + name: 'out_of_range_fiat_amount', + desc: '', + args: [min_amount, max_amount], + ); + } + + /// `An invoice with non-zero amount was received for the new order. Please send an invoice with a zero amount or no invoice at all.` + String get incorrect_invoice_amount_buyer_new_order { + return Intl.message( + 'An invoice with non-zero amount was received for the new order. Please send an invoice with a zero amount or no invoice at all.', + name: 'incorrect_invoice_amount_buyer_new_order', + desc: '', + args: [], + ); + } + + /// `The amount stated in the invoice is incorrect. Please send an invoice with an amount of {amount} satoshis, an invoice without an amount, or a lightning address.` + String incorrect_invoice_amount_buyer_add_invoice(Object amount) { + return Intl.message( + 'The amount stated in the invoice is incorrect. Please send an invoice with an amount of $amount satoshis, an invoice without an amount, or a lightning address.', + name: 'incorrect_invoice_amount_buyer_add_invoice', + desc: '', + args: [amount], + ); + } + + /// `The specified Sats amount is invalid.` + String get invalid_sats_amount { + return Intl.message( + 'The specified Sats amount is invalid.', + name: 'invalid_sats_amount', + desc: '', + args: [], + ); + } + + /// `The allowed Sats amount for this Mostro is between min {min_order_amount} and max {max_order_amount}. Please enter an amount within this range.` + String out_of_range_sats_amount( + Object min_order_amount, Object max_order_amount) { + return Intl.message( + 'The allowed Sats amount for this Mostro is between min $min_order_amount and max $max_order_amount. Please enter an amount within this range.', + name: 'out_of_range_sats_amount', + desc: '', + args: [min_order_amount, max_order_amount], + ); + } +} + +class AppLocalizationDelegate extends LocalizationsDelegate { + const AppLocalizationDelegate(); + + List get supportedLocales { + return const [ + Locale.fromSubtags(languageCode: 'en'), + ]; + } + + @override + bool isSupported(Locale locale) => _isSupported(locale); + @override + Future load(Locale locale) => S.load(locale); + @override + bool shouldReload(AppLocalizationDelegate old) => false; + + bool _isSupported(Locale locale) { + for (var supportedLocale in supportedLocales) { + if (supportedLocale.languageCode == locale.languageCode) { + return true; + } + } + return false; + } +} diff --git a/lib/l10n/intl_en.arb b/lib/l10n/intl_en.arb new file mode 100644 index 00000000..d4a9a072 --- /dev/null +++ b/lib/l10n/intl_en.arb @@ -0,0 +1,44 @@ +{ + "@@locale": "en", + "new_order": "Your offer has been published! Please wait until another user picks your order. It will be available for {expiration_hours} hours. You can cancel this order before another user picks it up by executing: cancel.", + "canceled": "You have cancelled the order ID: {id}!", + "pay_invoice": "Please pay this hold invoice of {amount} Sats for {fiat_code} {fiat_amount} to start the operation. If you do not pay it within {expiration_seconds} the trade will be cancelled.", + "add_invoice": "Please send me an invoice for {amount} satoshis equivalent to {fiat_code} {fiat_amount}. This is where I'll send the funds upon completion of the trade. If you don't provide the invoice within {expiration_seconds} this trade will be cancelled.", + "waiting_seller_to_pay": "Please wait a bit. I've sent a payment request to the seller to send the Sats for the order ID {id}. Once the payment is made, I'll connect you both. If the seller doesn't complete the payment within {expiration_seconds} minutes the trade will be cancelled.", + "waiting_buyer_invoice": "Payment received! Your Sats are now 'held' in your own wallet. Please wait a bit. I've requested the buyer to provide an invoice. Once they do, I'll connect you both. If they do not do so within {expiration_seconds} your Sats will be available in your wallet again and the trade will be cancelled.", + "buyer_invoice_accepted": "Invoice has been successfully saved!", + "hold_invoice_payment_accepted": "Get in touch with the seller, this is their npub {seller_npub} to get the details on how to send the fiat money for the order {id}, you must send {fiat_code} {fiat_amount} using {payment_method}. Once you send the fiat money, please let me know with fiat-sent.", + "buyer_took_order": "Get in touch with the buyer, this is their npub {buyer_npub} to inform them how to send you {fiat_code} {fiat_amount} through {payment_method}. I will notify you once the buyer indicates the fiat money has been sent. Afterward, you should verify if it has arrived. If the buyer does not respond, you can initiate a cancellation or a dispute. Remember, an administrator will NEVER contact you to resolve your order unless you open a dispute first.", + "fiat_sent_ok_buyer": "I have informed {seller_npub} that you have sent the fiat money. When the seller confirms they have received your fiat money, they should release the funds. If they refuse, you can open a dispute.", + "fiat_sent_ok_seller": "{buyer_npub} has informed that they have sent you the fiat money. Once you confirm receipt, please release the funds. After releasing, the money will go to the buyer and there will be no turning back, so only proceed if you are sure. If you want to release the Sats to the buyer, send me release-order-message.", + "released": "{seller_npub} has already released the Sats! Expect your invoice to be paid any time. Remember your wallet needs to be online to receive through the Lightning Network.", + "purchase_completed": "Your satoshis purchase has been completed successfully. I have paid your invoice, enjoy sound money!", + "hold_invoice_payment_settled": "Your Sats sale has been completed after confirming the payment from {buyer_npub}.", + "rate": "Please rate your counterparty", + "rate_received": "Rating successfully saved!", + "cooperative_cancel_initiated_by_you": "You have initiated the cancellation of the order ID: {id}. Your counterparty must agree to the cancellation too. 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.", + "cooperative_cancel_initiated_by_peer": "Your counterparty wants to cancel order ID: {id}. Note that no administrator will contact you regarding this cancellation unless you open a dispute first. If you agree on such cancellation, please send me cancel-order-message.", + "cooperative_cancel_accepted": "Order {id} has been successfully cancelled!", + "dispute_initiated_by_you": "You have initiated a dispute for order Id: {id}. A solver will be assigned to your dispute soon. Once assigned, I will share their npub with you, and only they will be able to assist you. You may contact the solver directly, but if they reach out first, please ask them to provide the token for your dispute. Your dispute token is: {user_token}.", + "dispute_initiated_by_peer": "Your counterparty has initiated a dispute for order Id: {id}. A solver will be assigned to your dispute soon. Once assigned, I will share their npub with you, and only they will be able to assist you. You may contact the solver directly, but if they reach out first, please ask them to provide the token for your dispute. Your dispute token is: {user_token}.", + "admin_took_dispute_admin": "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.", + "admin_took_dispute_users": "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.", + "admin_canceled_admin": "You have cancelled the order ID: {id}!", + "admin_canceled_users": "Admin has cancelled the order ID: {id}!", + "admin_settled_admin": "You have completed the order ID: {id}!", + "admin_settled_users": "Admin has completed the order ID: {id}!", + "is_not_your_dispute": "This dispute was not assigned to you!", + "not_found": "Dispute not found.", + "payment_failed": "I tried to send you the Sats but the payment of your invoice failed. I will try {payment_attempts} more times in {payment_retries_interval} minutes window. Please ensure your node/wallet is online.", + "invoice_updated": "Invoice has been successfully updated!", + "hold_invoice_payment_canceled": "The invoice was cancelled; your Sats will be available in your wallet again.", + "cant_do": "You are not allowed to {action} for this order!", + "admin_add_solver": "You have successfully added the solver {npub}.", + "is_not_your_order": "You did not create this order and are not authorized to {action} it.", + "not_allowed_by_status": "You are not allowed to {action} because order Id {id} status is {order_status}.", + "out_of_range_fiat_amount": "The requested amount is incorrect and may be outside the acceptable range. The minimum is {min_amount} and the maximum is {max_amount}.", + "incorrect_invoice_amount_buyer_new_order": "An invoice with non-zero amount was received for the new order. Please send an invoice with a zero amount or no invoice at all.", + "incorrect_invoice_amount_buyer_add_invoice": "The amount stated in the invoice is incorrect. Please send an invoice with an amount of {amount} satoshis, an invoice without an amount, or a lightning address.", + "invalid_sats_amount": "The specified Sats amount is invalid.", + "out_of_range_sats_amount": "The allowed Sats amount for this Mostro is between min {min_order_amount} and max {max_order_amount}. Please enter an amount within this range." +} diff --git a/lib/main.dart b/lib/main.dart index 721137cb..7f513f82 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -1,9 +1,10 @@ import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:mostro_mobile/data/repositories/mostro_order_repository.dart'; -import 'package:mostro_mobile/data/repositories/order_repository_interface.dart'; +import 'package:flutter_localizations/flutter_localizations.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:mostro_mobile/core/theme/app_theme.dart'; import 'package:mostro_mobile/presentation/auth/bloc/auth_state.dart'; -import 'package:mostro_mobile/services/mostro_service.dart'; +import 'package:mostro_mobile/providers/riverpod_providers.dart'; import 'package:mostro_mobile/services/nostr_service.dart'; import 'package:shared_preferences/shared_preferences.dart'; import 'package:mostro_mobile/core/routes/app_routes.dart'; @@ -13,6 +14,7 @@ import 'package:mostro_mobile/presentation/profile/bloc/profile_bloc.dart'; import 'package:mostro_mobile/presentation/auth/bloc/auth_bloc.dart'; import 'package:mostro_mobile/data/repositories/auth_repository.dart'; import 'package:mostro_mobile/core/utils/biometrics_helper.dart'; +import 'generated/l10n.dart'; void main() async { WidgetsFlutterBinding.ensureInitialized(); @@ -20,35 +22,29 @@ void main() async { final nostrService = NostrService(); await nostrService.init(); - final mostroService = MostroService(nostrService); - - final orderRepository = MostroOrderRepository(mostroService); - final prefs = await SharedPreferences.getInstance(); final isFirstLaunch = prefs.getBool('isFirstLaunch') ?? true; final biometricsHelper = BiometricsHelper(); - runApp(MyApp( - isFirstLaunch: isFirstLaunch, - orderRepository: orderRepository, - biometricsHelper: biometricsHelper)); + runApp(ProviderScope( + child: MyApp( + isFirstLaunch: isFirstLaunch, biometricsHelper: biometricsHelper))); } -class MyApp extends StatelessWidget { +class MyApp extends ConsumerWidget { final bool isFirstLaunch; final BiometricsHelper biometricsHelper; - final OrderRepository orderRepository; const MyApp({ super.key, required this.isFirstLaunch, - required this.orderRepository, required this.biometricsHelper, }); @override - Widget build(BuildContext context) { + Widget build(BuildContext context, WidgetRef ref) { + final homeBloc = ref.watch(homeBlocProvider); return MultiBlocProvider( providers: [ BlocProvider( @@ -59,7 +55,7 @@ class MyApp extends StatelessWidget { ), ), BlocProvider( - create: (context) => HomeBloc(orderRepository), + create: (context) => homeBloc, ), BlocProvider( create: (context) => ChatListBloc(), @@ -81,11 +77,18 @@ class MyApp extends StatelessWidget { title: 'Mostro', theme: ThemeData( primarySwatch: Colors.blue, - scaffoldBackgroundColor: const Color(0xFF1D212C), + scaffoldBackgroundColor: AppTheme.dark1, ), initialRoute: isFirstLaunch ? AppRoutes.welcome : AppRoutes.home, routes: AppRoutes.routes, onGenerateRoute: AppRoutes.onGenerateRoute, + localizationsDelegates: const [ + S.delegate, + GlobalMaterialLocalizations.delegate, + GlobalWidgetsLocalizations.delegate, + GlobalCupertinoLocalizations.delegate, + ], + supportedLocales: S.delegate.supportedLocales, ), ), ); diff --git a/lib/presentation/add_order/bloc/add_order_bloc.dart b/lib/presentation/add_order/bloc/add_order_bloc.dart index 3a8489cb..9a511fae 100644 --- a/lib/presentation/add_order/bloc/add_order_bloc.dart +++ b/lib/presentation/add_order/bloc/add_order_bloc.dart @@ -1,19 +1,50 @@ import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:mostro_mobile/data/models/enums/action.dart'; +import 'package:mostro_mobile/services/mostro_service.dart'; import 'add_order_event.dart'; import 'add_order_state.dart'; class AddOrderBloc extends Bloc { - AddOrderBloc() : super(const AddOrderState()) { + final MostroService mostroService; + + AddOrderBloc(this.mostroService) : super(const AddOrderState()) { on(_onChangeOrderType); on(_onSubmitOrder); + on(_onOrderUpdateReceived); } void _onChangeOrderType(ChangeOrderType event, Emitter emit) { emit(state.copyWith(currentType: event.orderType)); } - void _onSubmitOrder(SubmitOrder event, Emitter emit) { - // For now, just emit a success state - emit(state.copyWith(status: AddOrderStatus.success)); + Future _onSubmitOrder( + SubmitOrder event, Emitter emit) async { + emit(state.copyWith(status: AddOrderStatus.submitting)); + + try { + final order = await mostroService.publishOrder(event.order); + add(OrderUpdateReceived(order)); + } catch (e) { + emit(state.copyWith( + status: AddOrderStatus.failure, + errorMessage: e.toString(), + )); + } + } + + void _onOrderUpdateReceived( + OrderUpdateReceived event, Emitter emit) { + switch (event.order.action) { + case Action.newOrder: + emit(state.copyWith(status: AddOrderStatus.submitted)); + break; + case Action.outOfRangeSatsAmount: + case Action.outOfRangeFiatAmount: + emit(state.copyWith( + status: AddOrderStatus.failure, errorMessage: "Invalid amount")); + break; + default: + break; + } } } diff --git a/lib/presentation/add_order/bloc/add_order_event.dart b/lib/presentation/add_order/bloc/add_order_event.dart index 0a5cc3f6..f2aa0074 100644 --- a/lib/presentation/add_order/bloc/add_order_event.dart +++ b/lib/presentation/add_order/bloc/add_order_event.dart @@ -1,5 +1,7 @@ import 'package:equatable/equatable.dart'; -import 'package:mostro_mobile/presentation/home/bloc/home_state.dart'; +import 'package:mostro_mobile/data/models/enums/order_type.dart'; +import 'package:mostro_mobile/data/models/mostro_message.dart'; +import 'package:mostro_mobile/data/models/order.dart'; abstract class AddOrderEvent extends Equatable { const AddOrderEvent(); @@ -19,7 +21,7 @@ class ChangeOrderType extends AddOrderEvent { class SubmitOrder extends AddOrderEvent { final String fiatCode; - final double fiatAmount; + final int fiatAmount; final int satsAmount; final String paymentMethod; final OrderType orderType; @@ -32,6 +34,21 @@ class SubmitOrder extends AddOrderEvent { required this.orderType, }); + Order get order => Order( + kind: orderType, + fiatCode: fiatCode, + fiatAmount: fiatAmount, + paymentMethod: paymentMethod, + premium: 0); + @override - List get props => [fiatCode, fiatAmount, satsAmount, paymentMethod, orderType]; -} \ No newline at end of file + List get props => + [fiatCode, fiatAmount, satsAmount, paymentMethod, orderType]; +} + +class OrderUpdateReceived extends AddOrderEvent { + final MostroMessage order; + + const OrderUpdateReceived(this.order); +} + diff --git a/lib/presentation/add_order/bloc/add_order_state.dart b/lib/presentation/add_order/bloc/add_order_state.dart index ea8def45..c761042a 100644 --- a/lib/presentation/add_order/bloc/add_order_state.dart +++ b/lib/presentation/add_order/bloc/add_order_state.dart @@ -1,17 +1,19 @@ import 'package:equatable/equatable.dart'; -import 'package:mostro_mobile/presentation/home/bloc/home_state.dart'; +import 'package:mostro_mobile/data/models/enums/order_type.dart'; -enum AddOrderStatus { initial, loading, success, failure } +enum AddOrderStatus { initial, loading, success, submitting, submitted, failure } class AddOrderState extends Equatable { final OrderType currentType; final AddOrderStatus status; final String? errorMessage; + final String? currency; const AddOrderState({ this.currentType = OrderType.sell, this.status = AddOrderStatus.initial, this.errorMessage, + this.currency, }); AddOrderState copyWith({ @@ -27,5 +29,5 @@ class AddOrderState extends Equatable { } @override - List get props => [currentType, status, errorMessage]; -} \ No newline at end of file + List get props => [currentType, status, errorMessage, currency]; +} diff --git a/lib/presentation/add_order/screens/add_order_screen.dart b/lib/presentation/add_order/screens/add_order_screen.dart index cf92c76e..c570cb96 100644 --- a/lib/presentation/add_order/screens/add_order_screen.dart +++ b/lib/presentation/add_order/screens/add_order_screen.dart @@ -1,52 +1,68 @@ +import 'package:bitcoin_icons/bitcoin_icons.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:google_fonts/google_fonts.dart'; +import 'package:heroicons/heroicons.dart'; +import 'package:mostro_mobile/core/theme/app_theme.dart'; +import 'package:mostro_mobile/data/models/enums/order_type.dart'; +import 'package:mostro_mobile/generated/l10n.dart'; import 'package:mostro_mobile/presentation/add_order/bloc/add_order_bloc.dart'; import 'package:mostro_mobile/presentation/add_order/bloc/add_order_event.dart'; import 'package:mostro_mobile/presentation/add_order/bloc/add_order_state.dart'; -import 'package:mostro_mobile/presentation/home/bloc/home_state.dart'; -import 'package:mostro_mobile/presentation/widgets/bottom_nav_bar.dart'; -import 'package:mostro_mobile/presentation/widgets/custom_app_bar.dart'; +import 'package:mostro_mobile/presentation/widgets/currency_dropdown.dart'; +import 'package:mostro_mobile/presentation/widgets/currency_text_field.dart'; +import 'package:mostro_mobile/providers/exchange_service_provider.dart'; +import 'package:mostro_mobile/providers/riverpod_providers.dart'; + +class AddOrderScreen extends ConsumerWidget { + final _formKey = GlobalKey(); -class AddOrderScreen extends StatelessWidget { AddOrderScreen({super.key}); - final _fiatCodeController = TextEditingController(); final _fiatAmountController = TextEditingController(); final _satsAmountController = TextEditingController(); final _paymentMethodController = TextEditingController(); final _lightningInvoiceController = TextEditingController(); @override - Widget build(BuildContext context) { + Widget build(BuildContext context, WidgetRef ref) { + final orderRepo = ref.watch(mostroServiceProvider); + return BlocProvider( - create: (context) => AddOrderBloc(), + create: (context) => AddOrderBloc(orderRepo), child: BlocBuilder( builder: (context, state) { return Scaffold( - backgroundColor: const Color(0xFF1D212C), - appBar: const CustomAppBar(), + backgroundColor: AppTheme.dark1, + appBar: AppBar( + backgroundColor: Colors.transparent, + elevation: 0, + leading: IconButton( + icon: + const HeroIcon(HeroIcons.arrowLeft, color: AppTheme.cream1), + onPressed: () => Navigator.of(context).pop(), + ), + title: Text( + 'NEW ORDER', + style: TextStyle( + color: AppTheme.cream1, + fontFamily: GoogleFonts.robotoCondensed().fontFamily, + ), + ), + ), body: Column( children: [ Expanded( child: Container( - margin: const EdgeInsets.fromLTRB(16, 16, 16, 16), + margin: const EdgeInsets.all(16), decoration: BoxDecoration( - color: const Color(0xFF303544), + color: AppTheme.dark2, borderRadius: BorderRadius.circular(20), ), - child: Column( - children: [ - _buildTabs(context, state), - Expanded( - child: state.currentType == OrderType.sell - ? _buildSellForm(context) - : _buildBuyForm(context), - ), - ], - ), + child: _buildContent(context, state, ref), ), ), - const BottomNavBar(), ], ), ); @@ -55,10 +71,70 @@ class AddOrderScreen extends StatelessWidget { ); } + Widget _buildContent( + BuildContext context, AddOrderState state, WidgetRef ref) { + if (state.status == AddOrderStatus.submitting) { + return Center(child: CircularProgressIndicator()); + } else if (state.status == AddOrderStatus.submitted) { + return Padding( + padding: const EdgeInsets.all(16.0), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text(S.of(context).new_order('24'), + style: TextStyle(fontSize: 18, color: AppTheme.cream1), + textAlign: TextAlign.center, + ), + const SizedBox(height: 16), + ElevatedButton( + onPressed: () => + Navigator.of(context).pop(), + child: const Text('Back to Home'), + ), + ], + ), + ); + } else if (state.status == AddOrderStatus.failure) { + return Padding( + padding: const EdgeInsets.all(16.0), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text( + 'Order failed: ${state.errorMessage}', + style: const TextStyle(fontSize: 18, color: Colors.redAccent), + textAlign: TextAlign.center, + ), + const SizedBox(height: 16), + ElevatedButton( + onPressed: () => + Navigator.of(context).pop(), + child: const Text('Back to Home'), + ), + ], + ), + ); + } else { + return Form( + key: _formKey, + child: Column( + children: [ + _buildTabs(context, state), + Expanded( + child: state.currentType == OrderType.sell + ? _buildSellForm(context, ref) + : _buildBuyForm(context, ref), + ), + ], + ), + ); + } + } + Widget _buildTabs(BuildContext context, AddOrderState state) { return Container( decoration: const BoxDecoration( - color: Color(0xFF1D212C), + color: AppTheme.dark1, borderRadius: BorderRadius.only( topLeft: Radius.circular(20), topRight: Radius.circular(20), @@ -88,7 +164,7 @@ class AddOrderScreen extends StatelessWidget { child: Container( padding: const EdgeInsets.symmetric(vertical: 12), decoration: BoxDecoration( - color: isActive ? const Color(0xFF303544) : const Color(0xFF1D212C), + color: isActive ? AppTheme.dark2 : AppTheme.dark1, borderRadius: BorderRadius.only( topLeft: Radius.circular(isActive ? 20 : 0), topRight: Radius.circular(isActive ? 20 : 0), @@ -98,7 +174,7 @@ class AddOrderScreen extends StatelessWidget { text, textAlign: TextAlign.center, style: TextStyle( - color: isActive ? Colors.white : Colors.grey, + color: isActive ? AppTheme.cream1 : AppTheme.grey2, fontWeight: FontWeight.bold, ), ), @@ -106,99 +182,87 @@ class AddOrderScreen extends StatelessWidget { ); } - Widget _buildSellForm(BuildContext context) { + Widget _buildSellForm(BuildContext context, WidgetRef ref) { return SingleChildScrollView( padding: const EdgeInsets.all(16.0), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ const Text('Make sure your order is below 20K sats', - style: TextStyle(color: Colors.grey)), + style: TextStyle(color: AppTheme.grey2)), const SizedBox(height: 16), - _buildDropdownField('Fiat code'), + CurrencyDropdown(label: 'Fiat code'), const SizedBox(height: 16), - _buildTextField('Fiat amount', _fiatAmountController), + CurrencyTextField( + controller: _fiatAmountController, label: 'Fiat amount'), const SizedBox(height: 16), _buildFixedToggle(), const SizedBox(height: 16), _buildTextField('Sats amount', _satsAmountController, - suffix: Icons.menu), + suffix: Icon(BitcoinIcons.satoshi_v1_outline).icon), const SizedBox(height: 16), _buildTextField('Payment method', _paymentMethodController), const SizedBox(height: 32), - _buildActionButtons(context), + _buildActionButtons(context, ref, OrderType.sell), ], ), ); } - Widget _buildBuyForm(BuildContext context) { + Widget _buildBuyForm(BuildContext context, WidgetRef ref) { return SingleChildScrollView( padding: const EdgeInsets.all(16.0), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ const Text('Make sure your order is below 20K sats', - style: TextStyle(color: Colors.grey)), + style: TextStyle(color: AppTheme.grey2)), const SizedBox(height: 16), - _buildDropdownField('Fiat code'), + CurrencyDropdown(label: 'Fiat code'), const SizedBox(height: 16), - _buildTextField('Fiat amount', _fiatAmountController), + CurrencyTextField( + controller: _fiatAmountController, label: 'Fiat amount'), const SizedBox(height: 16), _buildFixedToggle(), const SizedBox(height: 16), _buildTextField('Sats amount', _satsAmountController, - suffix: Icons.menu), + suffix: Icon(BitcoinIcons.satoshi_v1_outline).icon), const SizedBox(height: 16), _buildTextField('Lightning Invoice without an amount', _lightningInvoiceController), const SizedBox(height: 16), _buildTextField('Payment method', _paymentMethodController), const SizedBox(height: 32), - _buildActionButtons(context), + _buildActionButtons(context, ref, OrderType.buy), ], ), ); } - Widget _buildDropdownField(String label) { - return Container( - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), - decoration: BoxDecoration( - color: const Color(0xFF1D212C), - borderRadius: BorderRadius.circular(8), - ), - child: DropdownButtonFormField( - decoration: InputDecoration( - border: InputBorder.none, - labelText: label, - labelStyle: const TextStyle(color: Colors.grey), - ), - dropdownColor: const Color(0xFF1D212C), - style: const TextStyle(color: Colors.white), - items: const [], // Add your fiat code options here - onChanged: (value) {}, - ), - ); - } - Widget _buildTextField(String label, TextEditingController controller, {IconData? suffix}) { return Container( padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), decoration: BoxDecoration( - color: const Color(0xFF1D212C), + color: AppTheme.dark1, borderRadius: BorderRadius.circular(8), ), - child: TextField( + child: TextFormField( controller: controller, - style: const TextStyle(color: Colors.white), + style: const TextStyle(color: AppTheme.cream1), decoration: InputDecoration( border: InputBorder.none, labelText: label, - labelStyle: const TextStyle(color: Colors.grey), - suffixIcon: suffix != null ? Icon(suffix, color: Colors.grey) : null, + labelStyle: const TextStyle(color: AppTheme.grey2), + suffixIcon: + suffix != null ? Icon(suffix, color: AppTheme.grey2) : null, ), + validator: (value) { + if (value == null || value.isEmpty) { + return 'Please enter a value'; + } + return null; + }, ), ); } @@ -206,47 +270,54 @@ class AddOrderScreen extends StatelessWidget { Widget _buildFixedToggle() { return Row( children: [ - const Text('Fixed', style: TextStyle(color: Colors.white)), + const Text('Fixed', style: TextStyle(color: AppTheme.cream1)), const SizedBox(width: 8), Switch( - value: false, // You should manage this state in the bloc + value: false, onChanged: (value) { - // Update the state in the bloc + // Update the state in the bloc if necessary }, ), ], ); } - Widget _buildActionButtons(BuildContext context) { + Widget _buildActionButtons( + BuildContext context, WidgetRef ref, OrderType orderType) { return Row( mainAxisAlignment: MainAxisAlignment.end, children: [ TextButton( - onPressed: () { - Navigator.of(context).pop(); - }, - child: const Text('CANCEL', style: TextStyle(color: Colors.orange)), + onPressed: () => Navigator.of(context).pop(), + child: const Text('CANCEL', style: TextStyle(color: AppTheme.red2)), ), const SizedBox(width: 16), ElevatedButton( onPressed: () { - // For now, just print the values and close the screen - print('Fiat Code: ${_fiatCodeController.text}'); - print('Fiat Amount: ${_fiatAmountController.text}'); - print('Sats Amount: ${_satsAmountController.text}'); - print('Payment Method: ${_paymentMethodController.text}'); - if (_lightningInvoiceController.text.isNotEmpty) { - print('Lightning Invoice: ${_lightningInvoiceController.text}'); + if (_formKey.currentState?.validate() ?? false) { + _submitOrder(context, ref, orderType); } - Navigator.of(context).pop(); }, style: ElevatedButton.styleFrom( - backgroundColor: const Color(0xFF8CC541), + backgroundColor: AppTheme.mostroGreen, ), child: const Text('SUBMIT'), ), ], ); } + + void _submitOrder(BuildContext context, WidgetRef ref, OrderType orderType) { + final selectedFiatCode = ref.read(selectedFiatCodeProvider); + + if (_formKey.currentState?.validate() ?? false) { + context.read().add(SubmitOrder( + fiatCode: selectedFiatCode ?? '', // Use selected fiat code + fiatAmount: int.tryParse(_fiatAmountController.text) ?? 0, + satsAmount: int.tryParse(_satsAmountController.text) ?? 0, + paymentMethod: _paymentMethodController.text, + orderType: orderType, + )); + } + } } diff --git a/lib/presentation/chat_list/screens/chat_list_screen.dart b/lib/presentation/chat_list/screens/chat_list_screen.dart index 5f721266..2171061c 100644 --- a/lib/presentation/chat_list/screens/chat_list_screen.dart +++ b/lib/presentation/chat_list/screens/chat_list_screen.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:google_fonts/google_fonts.dart'; import 'package:mostro_mobile/data/models/chat_model.dart'; import 'package:mostro_mobile/presentation/chat_list/bloc/chat_list_bloc.dart'; import 'package:mostro_mobile/presentation/chat_list/bloc/chat_list_event.dart'; @@ -25,7 +26,7 @@ class ChatListScreen extends StatelessWidget { ), child: Column( children: [ - const Padding( + Padding( padding: EdgeInsets.all(16.0), child: Text( 'Chats', @@ -33,6 +34,7 @@ class ChatListScreen extends StatelessWidget { color: Colors.white, fontSize: 24, fontWeight: FontWeight.bold, + fontFamily: GoogleFonts.robotoCondensed().fontFamily, ), ), ), diff --git a/lib/presentation/home/bloc/home_bloc.dart b/lib/presentation/home/bloc/home_bloc.dart index 2a29198d..23cadca9 100644 --- a/lib/presentation/home/bloc/home_bloc.dart +++ b/lib/presentation/home/bloc/home_bloc.dart @@ -1,16 +1,14 @@ import 'dart:async'; - import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:mostro_mobile/data/models/enums/order_type.dart'; import 'package:mostro_mobile/data/models/order_model.dart'; -import 'package:mostro_mobile/data/repositories/order_repository_interface.dart'; import 'package:mostro_mobile/presentation/home/bloc/home_event.dart'; import 'package:mostro_mobile/presentation/home/bloc/home_state.dart'; class HomeBloc extends Bloc { - final OrderRepository orderRepository; StreamSubscription? ordersSubscription; - HomeBloc(this.orderRepository) : super(HomeState.initial()) { + HomeBloc() : super(HomeState.initial()) { on(_onLoadOrders); on(_onChangeOrderType); on(_onOrderReceived); @@ -18,21 +16,6 @@ class HomeBloc extends Bloc { } Future _onLoadOrders(LoadOrders event, Emitter emit) async { - emit(state.copyWith(status: HomeStatus.loading)); - - await ordersSubscription?.cancel(); - - ordersSubscription = orderRepository.getPendingOrders().listen( - (order) { - add(OrderReceived(order)); - }, - onError: (error) { - add(OrdersError(error.toString())); - }, - onDone: () { - // EOSE - }, - ); } void _onOrderReceived(OrderReceived event, Emitter emit) { diff --git a/lib/presentation/home/bloc/home_event.dart b/lib/presentation/home/bloc/home_event.dart index d6a45d75..e4bd90cf 100644 --- a/lib/presentation/home/bloc/home_event.dart +++ b/lib/presentation/home/bloc/home_event.dart @@ -1,6 +1,6 @@ import 'package:equatable/equatable.dart'; +import 'package:mostro_mobile/data/models/enums/order_type.dart'; import 'package:mostro_mobile/data/models/order_model.dart'; -import 'package:mostro_mobile/presentation/home/bloc/home_state.dart'; abstract class HomeEvent extends Equatable { const HomeEvent(); diff --git a/lib/presentation/home/bloc/home_state.dart b/lib/presentation/home/bloc/home_state.dart index 81792c85..18916a94 100644 --- a/lib/presentation/home/bloc/home_state.dart +++ b/lib/presentation/home/bloc/home_state.dart @@ -1,10 +1,9 @@ import 'package:equatable/equatable.dart'; +import 'package:mostro_mobile/data/models/enums/order_type.dart'; import 'package:mostro_mobile/data/models/order_model.dart'; enum HomeStatus { initial, loading, loaded, error } -enum OrderType { buy, sell } - class HomeState extends Equatable { final HomeStatus status; final List allOrders; @@ -47,5 +46,6 @@ class HomeState extends Equatable { } @override - List get props => [status, allOrders, filteredOrders, orderType, errorMessage]; -} \ No newline at end of file + List get props => + [status, allOrders, filteredOrders, orderType, errorMessage]; +} diff --git a/lib/presentation/home/screens/home_screen.dart b/lib/presentation/home/screens/home_screen.dart index 1a0d8f7f..7d383576 100644 --- a/lib/presentation/home/screens/home_screen.dart +++ b/lib/presentation/home/screens/home_screen.dart @@ -1,21 +1,25 @@ import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:heroicons/heroicons.dart'; +import 'package:mostro_mobile/data/models/enums/order_type.dart'; +import 'package:mostro_mobile/data/models/nostr_event.dart'; +import 'package:mostro_mobile/presentation/home/bloc/home_bloc.dart'; import 'package:mostro_mobile/presentation/home/bloc/home_event.dart'; import 'package:mostro_mobile/presentation/home/bloc/home_state.dart'; import 'package:mostro_mobile/presentation/widgets/bottom_nav_bar.dart'; import 'package:mostro_mobile/presentation/widgets/custom_app_bar.dart'; +import 'package:mostro_mobile/presentation/widgets/order_filter.dart'; import 'package:mostro_mobile/presentation/widgets/order_list.dart'; -import '../bloc/home_bloc.dart'; +import 'package:mostro_mobile/providers/event_store_providers.dart'; -class HomeScreen extends StatelessWidget { +class HomeScreen extends ConsumerWidget { const HomeScreen({super.key}); @override - Widget build(BuildContext context) { + Widget build(BuildContext context, WidgetRef ref) { // Cargar las órdenes cuando se inicia la pantalla context.read().add(LoadOrders()); - return Scaffold( backgroundColor: const Color(0xFF1D212C), appBar: const CustomAppBar(), @@ -33,10 +37,10 @@ class HomeScreen extends StatelessWidget { child: Column( children: [ _buildTabs(), - _buildFilterButton(), + _buildFilterButton(context), const SizedBox(height: 6.0), Expanded( - child: _buildOrderList(), + child: _buildOrderList(ref), ), const BottomNavBar(), ], @@ -101,13 +105,24 @@ class HomeScreen extends StatelessWidget { ); } - Widget _buildFilterButton() { + Widget _buildFilterButton(BuildContext context) { return Container( padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), child: Row( children: [ OutlinedButton.icon( - onPressed: () {}, + onPressed: () { + showModalBottomSheet( + context: context, + backgroundColor: Colors.transparent, + builder: (BuildContext context) { + return Padding( + padding: const EdgeInsets.all(16.0), + child: OrderFilter(), + ); + }, + ); + }, icon: const HeroIcon(HeroIcons.funnel, style: HeroIconStyle.outline, color: Colors.white), label: const Text("FILTER", style: TextStyle(color: Colors.white)), @@ -132,27 +147,29 @@ class HomeScreen extends StatelessWidget { ); } - Widget _buildOrderList() { - return BlocBuilder( - builder: (context, state) { - if (state.status == HomeStatus.loading) { - return const Center(child: CircularProgressIndicator()); - } else if (state.status == HomeStatus.loaded) { - if (state.filteredOrders.isEmpty) { - return const Center( - child: Text( - 'No orders available for this type', - style: TextStyle(color: Colors.white), - ), - ); - } - return OrderList(orders: state.filteredOrders); - } else { - return const Center( - child: Text('Error loading orders', - style: TextStyle(color: Colors.white))); - } - }, - ); + Widget _buildOrderList(WidgetRef ref) { + final orderEventsAsync = ref.watch(orderEventsProvider); + + return orderEventsAsync.when( + data: (events) { + return BlocBuilder( + builder: (context, state) { + if (events.isEmpty) { + return const Center( + child: Text( + 'No orders available for this type', + style: TextStyle(color: Colors.white), + ), + ); + } + return OrderList( + orders: events + .where((evt) => evt.orderType == state.orderType) + .toList()); + }, + ); + }, + loading: () => Center(child: CircularProgressIndicator()), + error: (error, stack) => Center(child: Text('Error: $error'))); } } diff --git a/lib/presentation/order/bloc/order_details_bloc.dart b/lib/presentation/order/bloc/order_details_bloc.dart index dbef6bbc..4ed1dc45 100644 --- a/lib/presentation/order/bloc/order_details_bloc.dart +++ b/lib/presentation/order/bloc/order_details_bloc.dart @@ -1,28 +1,60 @@ import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:mostro_mobile/data/models/enums/action.dart'; +import 'package:mostro_mobile/data/models/enums/order_type.dart'; +import 'package:mostro_mobile/data/models/mostro_message.dart'; +import 'package:mostro_mobile/data/models/nostr_event.dart'; import 'package:mostro_mobile/presentation/order/bloc/order_details_event.dart'; import 'package:mostro_mobile/presentation/order/bloc/order_details_state.dart'; +import 'package:mostro_mobile/services/mostro_service.dart'; class OrderDetailsBloc extends Bloc { - OrderDetailsBloc() : super(const OrderDetailsState()) { + final MostroService mostroService; + + OrderDetailsBloc(this.mostroService) : super(const OrderDetailsState()) { on(_onLoadOrderDetails); on(_onCancelOrder); on(_onContinueOrder); + on(_onOrderUpdateReceived); } void _onLoadOrderDetails( LoadOrderDetails event, Emitter emit) { - emit(state.copyWith(status: OrderDetailsStatus.loading)); - // Aquí podrías cargar información adicional si fuera necesario emit(state.copyWith(status: OrderDetailsStatus.loaded, order: event.order)); } void _onCancelOrder(CancelOrder event, Emitter emit) { - // Implementar lógica para cancelar la orden - print('Cancelling order'); + emit(state.copyWith(status: OrderDetailsStatus.cancelled)); + } + + void _onContinueOrder( + ContinueOrder event, Emitter emit) async { + emit(state.copyWith(status: OrderDetailsStatus.loading)); + + late MostroMessage order; + + if (event.order.orderType == OrderType.buy) { + order = await mostroService.takeBuyOrder(event.order.orderId!); + } else { + order = await mostroService.takeSellOrder(event.order.orderId!); + } + + add(OrderUpdateReceived(order)); } - void _onContinueOrder(ContinueOrder event, Emitter emit) { - // Implementar lógica para continuar con la orden - print('Continuing with order'); + void _onOrderUpdateReceived( + OrderUpdateReceived event, Emitter emit) { + switch (event.order.action) { + case Action.addInvoice: + case Action.payInvoice: + case Action.waitingSellerToPay: + emit(state.copyWith(status: OrderDetailsStatus.done)); + break; + case Action.notAllowedByStatus: + emit(state.copyWith( + status: OrderDetailsStatus.error, errorMessage: "Not allowed by status")); + break; + default: + break; + } } } diff --git a/lib/presentation/order/bloc/order_details_event.dart b/lib/presentation/order/bloc/order_details_event.dart index fad0ff68..f4bd8add 100644 --- a/lib/presentation/order/bloc/order_details_event.dart +++ b/lib/presentation/order/bloc/order_details_event.dart @@ -1,5 +1,6 @@ +import 'package:dart_nostr/nostr/model/event/event.dart'; import 'package:equatable/equatable.dart'; -import 'package:mostro_mobile/data/models/order_model.dart'; +import 'package:mostro_mobile/data/models/mostro_message.dart'; abstract class OrderDetailsEvent extends Equatable { const OrderDetailsEvent(); @@ -9,7 +10,7 @@ abstract class OrderDetailsEvent extends Equatable { } class LoadOrderDetails extends OrderDetailsEvent { - final OrderModel order; + final NostrEvent order; const LoadOrderDetails(this.order); @@ -19,4 +20,20 @@ class LoadOrderDetails extends OrderDetailsEvent { class CancelOrder extends OrderDetailsEvent {} -class ContinueOrder extends OrderDetailsEvent {} +class ContinueOrder extends OrderDetailsEvent { + final NostrEvent order; + + const ContinueOrder(this.order); + + @override + List get props => [order]; +} + +class OrderUpdateReceived extends OrderDetailsEvent { + final MostroMessage order; + + const OrderUpdateReceived(this.order); + + @override + List get props => [order]; +} diff --git a/lib/presentation/order/bloc/order_details_state.dart b/lib/presentation/order/bloc/order_details_state.dart index 059faab4..9f11534f 100644 --- a/lib/presentation/order/bloc/order_details_state.dart +++ b/lib/presentation/order/bloc/order_details_state.dart @@ -1,11 +1,11 @@ +import 'package:dart_nostr/nostr/model/event/event.dart'; import 'package:equatable/equatable.dart'; -import 'package:mostro_mobile/data/models/order_model.dart'; -enum OrderDetailsStatus { initial, loading, loaded, error } +enum OrderDetailsStatus { initial, loading, loaded, error, cancelled, done } class OrderDetailsState extends Equatable { final OrderDetailsStatus status; - final OrderModel? order; + final NostrEvent? order; final String? errorMessage; const OrderDetailsState({ @@ -16,7 +16,7 @@ class OrderDetailsState extends Equatable { OrderDetailsState copyWith({ OrderDetailsStatus? status, - OrderModel? order, + NostrEvent? order, String? errorMessage, }) { return OrderDetailsState( diff --git a/lib/presentation/order/screens/order_details_screen.dart b/lib/presentation/order/screens/order_details_screen.dart index 4292ba3f..a974a1f2 100644 --- a/lib/presentation/order/screens/order_details_screen.dart +++ b/lib/presentation/order/screens/order_details_screen.dart @@ -1,212 +1,248 @@ +import 'package:dart_nostr/nostr/model/event/event.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:google_fonts/google_fonts.dart'; import 'package:heroicons/heroicons.dart'; -import 'package:mostro_mobile/data/models/order_model.dart'; +import 'package:mostro_mobile/core/theme/app_theme.dart'; +import 'package:mostro_mobile/data/models/nostr_event.dart'; import 'package:mostro_mobile/presentation/order/bloc/order_details_bloc.dart'; import 'package:mostro_mobile/presentation/order/bloc/order_details_event.dart'; import 'package:mostro_mobile/presentation/order/bloc/order_details_state.dart'; -import 'package:mostro_mobile/presentation/widgets/bottom_nav_bar.dart'; +import 'package:mostro_mobile/presentation/widgets/currency_text_field.dart'; +import 'package:mostro_mobile/presentation/widgets/exchange_rate_widget.dart'; +import 'package:mostro_mobile/providers/exchange_service_provider.dart'; +import 'package:mostro_mobile/providers/riverpod_providers.dart'; -class OrderDetailsScreen extends StatelessWidget { - final OrderModel initialOrder; +class OrderDetailsScreen extends ConsumerWidget { + final NostrEvent initialOrder; - const OrderDetailsScreen({super.key, required this.initialOrder}); + final TextEditingController _satsAmountController = TextEditingController(); + + OrderDetailsScreen({super.key, required this.initialOrder}); @override - Widget build(BuildContext context) { + Widget build(BuildContext context, WidgetRef ref) { + final mostroService = ref.watch(mostroServiceProvider); return BlocProvider( create: (context) => - OrderDetailsBloc()..add(LoadOrderDetails(initialOrder)), - child: BlocBuilder( - builder: (context, state) { - if (state.status == OrderDetailsStatus.loading) { - return const Center(child: CircularProgressIndicator()); - } - if (state.status == OrderDetailsStatus.error) { - return Center( - child: Text(state.errorMessage ?? 'An error occurred')); + OrderDetailsBloc(mostroService)..add(LoadOrderDetails(initialOrder)), + child: BlocConsumer( + listener: (context, state) { + if (state.status == OrderDetailsStatus.done) { + Navigator.of(context).pop(); } - if (state.order == null) { - return const Center(child: Text('Order not found')); + }, + builder: (context, state) { + switch (state.status) { + case OrderDetailsStatus.loading: + return const Center(child: CircularProgressIndicator()); + case OrderDetailsStatus.error: + return _buildErrorScreen(state.errorMessage, context); + case OrderDetailsStatus.cancelled: + case OrderDetailsStatus.done: + return _buildCompletionMessage(context, state); + case OrderDetailsStatus.loaded: + return _buildContent(context, ref, state.order!); + default: + return const Center(child: Text('Order not found')); } - return _buildContent(context, state.order!); }, ), ); } - Widget _buildContent(BuildContext context, OrderModel order) { + Widget _buildErrorScreen(String? errorMessage, BuildContext context) { return Scaffold( - backgroundColor: const Color(0xFF1D212C), - appBar: AppBar( - backgroundColor: Colors.transparent, - elevation: 0, - leading: IconButton( - icon: const HeroIcon(HeroIcons.arrowLeft, color: Colors.white), - onPressed: () => Navigator.of(context).pop(), - ), - title: - const Text('ORDER DETAILS', style: TextStyle(color: Colors.white)), - actions: [ - IconButton( - icon: const HeroIcon(HeroIcons.plus, color: Colors.white), - onPressed: () { - // Implementar lógica para añadir - }, - ), - IconButton( - icon: const HeroIcon(HeroIcons.bolt, - style: HeroIconStyle.solid, color: Color(0xFF8CC541)), - onPressed: () { - // Implementar lógica para acción de rayo - }, + backgroundColor: AppTheme.dark1, + appBar: _buildAppBar('Error', context), + body: Center( + child: Padding( + padding: const EdgeInsets.all(16.0), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text( + errorMessage ?? 'An unexpected error occurred.', + style: const TextStyle( + color: Colors.red, + fontSize: 18, + fontWeight: FontWeight.bold, + ), + textAlign: TextAlign.center, + ), + const SizedBox(height: 32), + ElevatedButton( + onPressed: () => Navigator.of(context).pop(), + style: ElevatedButton.styleFrom( + backgroundColor: AppTheme.cream1, + ), + child: const Text('Return to Main Screen'), + ), + ], ), - ], + ), ), - body: Column( - children: [ - Expanded( - child: SingleChildScrollView( - child: Padding( - padding: const EdgeInsets.all(16.0), - child: Column( - children: [ - _buildSellerInfo(order), - const SizedBox(height: 16), - _buildSellerAmount(order), - const SizedBox(height: 16), - _buildExchangeRate(order), - const SizedBox(height: 16), - _buildBuyerInfo(order), - const SizedBox(height: 16), - _buildBuyerAmount(order), - const SizedBox(height: 24), - _buildActionButtons(context), - ], + ); + } + + Widget _buildCompletionMessage( + BuildContext context, OrderDetailsState state) { + final message = state.status == OrderDetailsStatus.cancelled + ? 'Order has been cancelled.' + : state.errorMessage ?? + 'Order has been completed successfully!'; // Handles custom errors or success messages + return Scaffold( + backgroundColor: AppTheme.dark1, + appBar: _buildAppBar('Completion', context), + body: Center( + child: Padding( + padding: const EdgeInsets.all(16.0), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text( + message, + style: const TextStyle( + color: AppTheme.cream1, + fontSize: 18, + fontWeight: FontWeight.bold, ), + textAlign: TextAlign.center, ), - ), + const SizedBox(height: 16), + const Text( + 'Thank you for using our service!', + style: TextStyle(color: AppTheme.grey2), + textAlign: TextAlign.center, + ), + const SizedBox(height: 32), + ElevatedButton( + onPressed: () => Navigator.of(context).pop(), + style: ElevatedButton.styleFrom( + backgroundColor: AppTheme.mostroGreen, + ), + child: const Text('Return to Main Screen'), + ), + ], ), - const BottomNavBar(), - ], + ), ), ); } - Widget _buildSellerInfo(OrderModel order) { - return Container( - padding: const EdgeInsets.all(16), - decoration: BoxDecoration( - color: const Color(0xFF303544), - borderRadius: BorderRadius.circular(12), + Widget _buildContent(BuildContext context, WidgetRef ref, NostrEvent order) { + return Scaffold( + backgroundColor: AppTheme.dark1, + appBar: _buildAppBar( + '${order.orderType?.value.toUpperCase()} BITCOIN', context), + body: SingleChildScrollView( + child: Padding( + padding: const EdgeInsets.all(16.0), + child: Column( + children: [ + _buildSellerInfo(order), + const SizedBox(height: 16), + _buildSellerAmount(order, ref), + const SizedBox(height: 16), + ExchangeRateWidget(currency: order.currency!), + const SizedBox(height: 16), + _buildBuyerInfo(order), + const SizedBox(height: 16), + _buildBuyerAmount(order), + const SizedBox(height: 24), + _buildActionButtons(context), + ], + ), + ), + ), + ); + } + + PreferredSizeWidget? _buildAppBar(String title, BuildContext context) { + return AppBar( + backgroundColor: Colors.transparent, + elevation: 0, + leading: IconButton( + icon: const HeroIcon(HeroIcons.arrowLeft, color: Colors.white), + onPressed: () => Navigator.of(context).pop(), + ), + title: Text( + title, + style: TextStyle( + color: Colors.white, + fontFamily: GoogleFonts.robotoCondensed().fontFamily, + ), ), - child: Row( + ); + } + + Widget _buildSellerInfo(NostrEvent order) { + return _infoContainer( + Row( children: [ - CircleAvatar( - backgroundImage: NetworkImage(order.sellerAvatar), + const CircleAvatar( + backgroundColor: AppTheme.grey2, + child: Text('S', style: TextStyle(color: Colors.white)), ), const SizedBox(width: 12), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text(order.sellerName, + Text(order.name!, style: const TextStyle( color: Colors.white, fontWeight: FontWeight.bold)), - Text('${order.sellerRating}/5 (${order.sellerReviewCount})', - style: const TextStyle(color: Color(0xFF8CC541))), + Text( + '${order.rating?.totalRating}/${order.rating?.maxRate} (${order.rating?.totalReviews})', + style: const TextStyle(color: AppTheme.mostroGreen), + ), ], ), ), TextButton( onPressed: () { - // Implementar lógica para leer reseñas + // Implement review logic }, child: const Text('Read reviews', - style: TextStyle(color: Color(0xFF8CC541))), + style: TextStyle(color: AppTheme.mostroGreen)), ), ], ), ); } - Widget _buildSellerAmount(OrderModel order) { - return Container( - padding: const EdgeInsets.all(16), - decoration: BoxDecoration( - color: const Color(0xFF303544), - borderRadius: BorderRadius.circular(12), - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text('${order.fiatAmount} ${order.fiatCurrency} (${order.premium})', - style: const TextStyle( - color: Colors.white, - fontSize: 18, - fontWeight: FontWeight.bold)), - Text('${order.satsAmount} sats', - style: const TextStyle(color: Colors.grey)), - const SizedBox(height: 8), - Row( + Widget _buildSellerAmount(NostrEvent order, WidgetRef ref) { + final exchangeRateAsyncValue = + ref.watch(exchangeRateProvider(order.currency!)); + return exchangeRateAsyncValue.when( + loading: () => const CircularProgressIndicator(), + error: (error, _) => Text('Error: $error'), + data: (exchangeRate) { + return _infoContainer( + Column( + crossAxisAlignment: CrossAxisAlignment.start, children: [ - const HeroIcon(HeroIcons.creditCard, - style: HeroIconStyle.outline, color: Colors.white, size: 16), - const SizedBox(width: 8), - Text(order.paymentMethod, - style: const TextStyle(color: Colors.white)), + Text('${order.fiatAmount} ${order.currency} (${order.premium}%)', + style: const TextStyle( + color: AppTheme.cream1, + fontSize: 18, + fontWeight: FontWeight.bold)), + Text('${order.amount} sats', + style: const TextStyle(color: AppTheme.grey2)), ], ), - ], - ), + ); + }, ); } - Widget _buildExchangeRate(OrderModel order) { - return Container( - padding: const EdgeInsets.all(16), - decoration: BoxDecoration( - color: const Color(0xFF303544), - borderRadius: BorderRadius.circular(12), - ), - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Text('1 BTC = \$ ${order.exchangeRate}', - style: const TextStyle(color: Colors.white)), - Row( - children: [ - const Text('price yado.io', style: TextStyle(color: Colors.grey)), - const SizedBox(width: 4), - Container( - padding: const EdgeInsets.all(4), - decoration: BoxDecoration( - color: Colors.grey.withOpacity(0.3), - shape: BoxShape.circle, - ), - child: const HeroIcon(HeroIcons.arrowsUpDown, - style: HeroIconStyle.outline, - color: Colors.white, - size: 12), - ), - ], - ), - ], - ), - ); - } - - Widget _buildBuyerInfo(OrderModel order) { - return Container( - padding: const EdgeInsets.all(16), - decoration: BoxDecoration( - color: const Color(0xFF303544), - borderRadius: BorderRadius.circular(12), - ), - child: const Row( + Widget _buildBuyerInfo(NostrEvent order) { + return _infoContainer( + const Row( children: [ CircleAvatar( - backgroundColor: Colors.grey, + backgroundColor: AppTheme.grey2, child: Text('A', style: TextStyle(color: Colors.white)), ), SizedBox(width: 12), @@ -217,44 +253,34 @@ class OrderDetailsScreen extends StatelessWidget { Text('Anon (you)', style: TextStyle( color: Colors.white, fontWeight: FontWeight.bold)), - Text('0/5 (0)', style: TextStyle(color: Colors.grey)), ], ), ), - HeroIcon(HeroIcons.bolt, - style: HeroIconStyle.solid, color: Color(0xFF8CC541)), ], ), ); } - Widget _buildBuyerAmount(OrderModel order) { + Widget _infoContainer(Widget child) { return Container( padding: const EdgeInsets.all(16), decoration: BoxDecoration( - color: const Color(0xFF303544), + color: AppTheme.dark2, borderRadius: BorderRadius.circular(12), ), - child: Column( + child: child, + ); + } + + Widget _buildBuyerAmount(NostrEvent order) { + return _infoContainer( + Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text('${order.buyerSatsAmount} sats', - style: const TextStyle( - color: Colors.white, - fontSize: 18, - fontWeight: FontWeight.bold)), - Text('\$ ${order.buyerFiatAmount}', - style: const TextStyle(color: Colors.grey)), + CurrencyTextField(controller: _satsAmountController, label: 'Sats'), const SizedBox(height: 8), - const Row( - children: [ - HeroIcon(HeroIcons.bolt, - style: HeroIconStyle.solid, color: Colors.white, size: 16), - SizedBox(width: 8), - Text('Bitcoin Lightning Network', - style: TextStyle(color: Colors.white)), - ], - ), + Text('\$ ${order.amount}', + style: const TextStyle(color: AppTheme.grey2)), ], ), ); @@ -266,12 +292,10 @@ class OrderDetailsScreen extends StatelessWidget { Expanded( child: ElevatedButton( onPressed: () { - context.read().add(CancelOrder()); + Navigator.of(context).pop(); }, style: ElevatedButton.styleFrom( backgroundColor: Colors.red, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(8)), ), child: const Text('CANCEL'), ), @@ -280,12 +304,10 @@ class OrderDetailsScreen extends StatelessWidget { Expanded( child: ElevatedButton( onPressed: () { - context.read().add(ContinueOrder()); + context.read().add(ContinueOrder(initialOrder)); }, style: ElevatedButton.styleFrom( - backgroundColor: const Color(0xFF8CC541), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(8)), + backgroundColor: AppTheme.mostroGreen, ), child: const Text('CONTINUE'), ), diff --git a/lib/presentation/payment_qr/screens/payment_qr_screen.dart b/lib/presentation/payment_qr/screens/payment_qr_screen.dart index ea749b65..0da0c2e8 100644 --- a/lib/presentation/payment_qr/screens/payment_qr_screen.dart +++ b/lib/presentation/payment_qr/screens/payment_qr_screen.dart @@ -1,6 +1,5 @@ import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:qr_flutter/qr_flutter.dart'; import '../bloc/payment_qr_bloc.dart'; import '../bloc/payment_qr_event.dart'; import '../bloc/payment_qr_state.dart'; @@ -37,12 +36,6 @@ class PaymentQrScreen extends StatelessWidget { style: TextStyle(color: Colors.white), ), const SizedBox(height: 20), - QrImage( - data: state.qrData, - version: QrVersions.auto, - size: 200.0, - foregroundColor: Colors.white, - ), const SizedBox(height: 20), Text( 'Expires in: ${state.expiresIn}', diff --git a/lib/presentation/widgets/currency_dropdown.dart b/lib/presentation/widgets/currency_dropdown.dart new file mode 100644 index 00000000..c0129464 --- /dev/null +++ b/lib/presentation/widgets/currency_dropdown.dart @@ -0,0 +1,70 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:mostro_mobile/core/theme/app_theme.dart'; +import 'package:mostro_mobile/providers/exchange_service_provider.dart'; + +class CurrencyDropdown extends ConsumerWidget { + final String label; + + const CurrencyDropdown({ + super.key, + required this.label, + }); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final currencyCodesAsync = ref.watch(currencyCodesProvider); + final selectedFiatCode = ref.watch(selectedFiatCodeProvider); + + return Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + decoration: BoxDecoration( + color: AppTheme.dark1, + borderRadius: BorderRadius.circular(8), + ), + child: currencyCodesAsync.when( + loading: () => const Center( + child: SizedBox( + height: 24, + width: 24, + child: CircularProgressIndicator(), + ), + ), + error: (error, stackTrace) => Row( + children: [ + Text('Failed to load currencies'), + TextButton( + onPressed: () => ref.refresh(currencyCodesProvider), + child: const Text('Retry'), + ), + ], + ), + data: (currencyCodes) { + final items = currencyCodes.keys.map((code) { + return DropdownMenuItem( + value: code, + child: Text( + '$code - ${currencyCodes[code]}', + style: const TextStyle(color: AppTheme.cream1), + ), + ); + }).toList(); + + return DropdownButtonFormField( + decoration: InputDecoration( + border: InputBorder.none, + labelText: label, + labelStyle: const TextStyle(color: AppTheme.grey2), + ), + dropdownColor: AppTheme.dark1, + style: TextStyle(color: AppTheme.cream1), + items: items, + value: selectedFiatCode, + onChanged: (value) => + ref.read(selectedFiatCodeProvider.notifier).state = value, + ); + }, + ), + ); + } +} diff --git a/lib/presentation/widgets/currency_text_field.dart b/lib/presentation/widgets/currency_text_field.dart new file mode 100644 index 00000000..488efd2e --- /dev/null +++ b/lib/presentation/widgets/currency_text_field.dart @@ -0,0 +1,42 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:mostro_mobile/services/currency_input_formatter.dart'; + +class CurrencyTextField extends StatelessWidget { + final TextEditingController controller; + final String label; + + const CurrencyTextField( + {super.key, required this.controller, required this.label}); + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + decoration: BoxDecoration( + color: const Color(0xFF1D212C), + borderRadius: BorderRadius.circular(8), + ), + child: TextFormField( + controller: controller, + keyboardType: TextInputType.numberWithOptions(decimal: true), + inputFormatters: [ + CurrencyInputFormatter(), + FilteringTextInputFormatter.allow(RegExp(r'^\d*\.?\d*')), + ], + style: const TextStyle(color: Colors.white), + decoration: InputDecoration( + border: InputBorder.none, + labelText: label, + labelStyle: const TextStyle(color: Colors.grey), + ), + validator: (value) { + if (value == null || value.isEmpty) { + return 'Please enter a value'; + } + return null; + }, + ), + ); + } +} diff --git a/lib/presentation/widgets/custom_app_bar.dart b/lib/presentation/widgets/custom_app_bar.dart index 8944c74a..de620783 100644 --- a/lib/presentation/widgets/custom_app_bar.dart +++ b/lib/presentation/widgets/custom_app_bar.dart @@ -19,6 +19,7 @@ class CustomAppBar extends StatelessWidget implements PreferredSizeWidget { ), actions: [ IconButton( + key: Key('createOrderButton'), icon: const HeroIcon(HeroIcons.plus, style: HeroIconStyle.outline, color: Colors.white), onPressed: () { diff --git a/lib/presentation/widgets/exchange_rate_widget.dart b/lib/presentation/widgets/exchange_rate_widget.dart new file mode 100644 index 00000000..b6f12203 --- /dev/null +++ b/lib/presentation/widgets/exchange_rate_widget.dart @@ -0,0 +1,78 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:intl/intl.dart'; +import 'package:mostro_mobile/providers/exchange_service_provider.dart'; + +class ExchangeRateWidget extends ConsumerWidget { + final String currency; + + const ExchangeRateWidget({ + super.key, + required this.currency, + }); + + @override + Widget build(BuildContext context, WidgetRef ref) { + // Watch the provider for the specific currency + final exchangeRateAsyncValue = ref.watch(exchangeRateProvider(currency)); + + return exchangeRateAsyncValue.when( + loading: () => const CircularProgressIndicator(), + error: (error, _) => Text('Error: $error'), + data: (exchangeRate) { + return Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: const Color(0xFF303544), + borderRadius: BorderRadius.circular(12), + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + '1 BTC = ${NumberFormat.currency( + symbol: '', + decimalDigits: 2, + ).format(exchangeRate)} $currency', + style: const TextStyle(color: Colors.white), + ), + Row( + children: [ + Text('price in $currency', + style: const TextStyle(color: Colors.grey)), + const SizedBox(width: 4), + GestureDetector( + onTap: () { + // Trigger refresh for this specific currency + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Refreshing exchange rate...'), + duration: Duration(seconds: 1), + ), + ); + ref + .read(exchangeRateProvider(currency).notifier) + .fetchExchangeRate(currency); + }, + child: Container( + padding: const EdgeInsets.all(4), + decoration: BoxDecoration( + color: Colors.grey.withOpacity(0.3), + shape: BoxShape.circle, + ), + child: const Icon( + Icons.sync, + color: Colors.white, + size: 12, + ), + ), + ), + ], + ), + ], + ), + ); + }, + ); + } +} diff --git a/lib/presentation/widgets/order_filter.dart b/lib/presentation/widgets/order_filter.dart new file mode 100644 index 00000000..05fa1873 --- /dev/null +++ b/lib/presentation/widgets/order_filter.dart @@ -0,0 +1,85 @@ +import 'package:flutter/material.dart'; +import 'package:heroicons/heroicons.dart'; +import 'package:mostro_mobile/core/theme/app_theme.dart'; + +class OrderFilter extends StatelessWidget { + const OrderFilter({super.key}); + + @override + Widget build(BuildContext context) { + return Container( + width: 300, + padding: EdgeInsets.all(16), + decoration: BoxDecoration( + color: AppTheme.cream1, + borderRadius: BorderRadius.circular(10), + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(0.1), + blurRadius: 5, + offset: Offset(0, 3), + ), + ], + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Row( + children: [ + const HeroIcon(HeroIcons.funnel, + style: HeroIconStyle.outline, color: AppTheme.dark2), + SizedBox(width: 8), + Text( + 'FILTER', + style: AppTheme.theme.textTheme.headlineSmall!.copyWith( + color: AppTheme.dark2, + ), + ), + ], + ), + IconButton( + icon: Icon(Icons.close, color: AppTheme.dark2, size: 20), + onPressed: () { + Navigator.of(context).pop(); + }, + ), + ], + ), + SizedBox(height: 20), + buildDropdownSection(context, 'Fiat currencies', []), + buildDropdownSection(context, 'Payment methods', []), + buildDropdownSection(context, 'Countries', []), + buildDropdownSection(context, 'Rating', []), + ], + ), + ); + } + + Widget buildDropdownSection( + BuildContext context, String title, List> items) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 8.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + DropdownButtonFormField( + decoration: InputDecoration( + border: InputBorder.none, + labelText: title, + labelStyle: const TextStyle(color: AppTheme.mostroGreen), + ), + dropdownColor: AppTheme.dark1, + style: TextStyle(color: AppTheme.cream1), + items: items, + value: '', onChanged: (String? value) { }, + ), + SizedBox(height: 4), + ], + ), + ); + } +} diff --git a/lib/presentation/widgets/order_list.dart b/lib/presentation/widgets/order_list.dart index 3e75e46a..4a165b81 100644 --- a/lib/presentation/widgets/order_list.dart +++ b/lib/presentation/widgets/order_list.dart @@ -1,9 +1,9 @@ +import 'package:dart_nostr/nostr/model/event/event.dart'; import 'package:flutter/material.dart'; -import '../../data/models/order_model.dart'; import 'order_list_item.dart'; class OrderList extends StatelessWidget { - final List orders; + final List orders; const OrderList({super.key, required this.orders}); diff --git a/lib/presentation/widgets/order_list_item.dart b/lib/presentation/widgets/order_list_item.dart index 137c7670..a0befac3 100644 --- a/lib/presentation/widgets/order_list_item.dart +++ b/lib/presentation/widgets/order_list_item.dart @@ -1,11 +1,14 @@ +import 'package:dart_nostr/nostr/model/event/event.dart'; import 'package:flutter/material.dart'; import 'package:heroicons/heroicons.dart'; +import 'package:mostro_mobile/core/theme/app_theme.dart'; +import 'package:mostro_mobile/data/models/enums/order_type.dart'; +import 'package:mostro_mobile/data/models/nostr_event.dart'; import 'package:mostro_mobile/presentation/order/screens/order_details_screen.dart'; -import 'package:mostro_mobile/data/models/order_model.dart'; import 'package:google_fonts/google_fonts.dart'; class OrderListItem extends StatelessWidget { - final OrderModel order; + final NostrEvent order; const OrderListItem({super.key, required this.order}); @@ -34,11 +37,11 @@ class OrderListItem extends StatelessWidget { mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ Text( - '${order.user} ${order.rating}/5 (${order.ratingCount})', + '${order.name} ${order.rating?.totalRating ?? 0}/${order.rating?.maxRate ?? 5} (${order.rating?.totalReviews ?? 0})', style: const TextStyle(color: Colors.white), ), Text( - 'Time: ${order.timeAgo}', + 'Time: ${order.expiration}', style: const TextStyle(color: Colors.white), ), ], @@ -48,68 +51,16 @@ class OrderListItem extends StatelessWidget { mainAxisAlignment: MainAxisAlignment.spaceBetween, crossAxisAlignment: CrossAxisAlignment.start, children: [ - Expanded( - flex: 3, - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text.rich( - TextSpan( - children: [ - buildStyledTextSpan( - 'offering ', - '${order.amount}', - isValue: true, - isBold: true, - ), - const TextSpan( - text: "sats", - style: TextStyle( - color: Colors.white, - fontWeight: FontWeight.normal, - fontSize: 16.0, - ), - ), - ], - ), - ), - const SizedBox(height: 8.0), - Text.rich( - TextSpan( - children: [ - buildStyledTextSpan( - 'for ', - '${order.fiatAmount}', - isValue: true, - isBold: true, - ), - TextSpan( - text: '${order.fiatCurrency} ', - style: const TextStyle( - color: Colors.white, - fontSize: 16.0, - ), - ), - TextSpan( - text: '(${order.premium}%)', - style: const TextStyle( - color: Colors.white, - fontSize: 16.0, - ), - ), - ], - ), - ), - ], - ), - ), + _getOrder(order), const SizedBox(width: 16), Expanded( flex: 4, child: Row( children: [ HeroIcon( - _getPaymentMethodIcon(order.paymentMethod), + _getPaymentMethodIcon(order.paymentMethods.isNotEmpty + ? order.paymentMethods[0] + : ''), style: HeroIconStyle.outline, color: Colors.white, size: 16, @@ -117,7 +68,9 @@ class OrderListItem extends StatelessWidget { const SizedBox(width: 4), Flexible( child: Text( - order.paymentMethod, + order.paymentMethods.isNotEmpty + ? order.paymentMethods[0] + : 'No payment method', style: const TextStyle(color: Colors.grey), overflow: TextOverflow.visible, softWrap: true, @@ -128,8 +81,7 @@ class OrderListItem extends StatelessWidget { ), ], ), - - const SizedBox(height: 8), // Optional spacer after the row + const SizedBox(height: 8), ], ), ), @@ -137,6 +89,71 @@ class OrderListItem extends StatelessWidget { ); } + Expanded _getOrder(NostrEvent order) { + String offering; + if (order.orderType == OrderType.buy) { + offering = 'Buying'; + } else { + offering = 'Selling'; + } + + return Expanded( + flex: 3, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text.rich( + TextSpan( + children: [ + _buildStyledTextSpan( + offering, + (order.amount != null && order.amount != '0') ? ' ${order.amount!}' : '', + isValue: true, + isBold: true, + ), + const TextSpan( + text: "sats", + style: TextStyle( + color: Colors.white, + fontWeight: FontWeight.normal, + fontSize: 16.0, + ), + ), + ], + ), + ), + const SizedBox(height: 8.0), + Text.rich( + TextSpan( + children: [ + _buildStyledTextSpan( + 'for ', + '${order.fiatAmount}', + isValue: true, + isBold: true, + ), + TextSpan( + text: '${order.currency} ', + style: const TextStyle( + color: Colors.white, + fontSize: 16.0, + ), + ), + TextSpan( + text: '(${order.premium}%)', + style: const TextStyle( + color: Colors.white, + fontSize: 16.0, + ), + ), + ], + ), + ), + ], + ), + ); + } + HeroIcons _getPaymentMethodIcon(String method) { switch (method.toLowerCase()) { case 'wire transfer': @@ -150,12 +167,12 @@ class OrderListItem extends StatelessWidget { } } - TextSpan buildStyledTextSpan(String label, String value, + TextSpan _buildStyledTextSpan(String label, String value, {bool isValue = false, bool isBold = false}) { return TextSpan( text: label, style: TextStyle( - color: Colors.white, + color: AppTheme.cream1, fontWeight: FontWeight.normal, fontSize: isValue ? 16.0 : 24.0, fontFamily: GoogleFonts.robotoCondensed().fontFamily, diff --git a/lib/providers/event_store_providers.dart b/lib/providers/event_store_providers.dart new file mode 100644 index 00000000..90ef3ba3 --- /dev/null +++ b/lib/providers/event_store_providers.dart @@ -0,0 +1,30 @@ +import 'package:dart_nostr/nostr/model/event/event.dart'; +import 'package:dart_nostr/nostr/model/request/filter.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:mostro_mobile/data/repositories/open_orders_repository.dart'; +import 'package:mostro_mobile/services/nostr_service.dart'; + +final nostrServicerProvider = Provider((ref) { + return NostrService()..init(); +}); + +final orderRepositoryProvider = Provider((ref) { + final nostrService = ref.read(nostrServicerProvider); + return OpenOrdersRepository(nostrService); +}); + +/// Event kind 38383 represents order events in the Nostr protocol as per NIP-69 +const orderEventKind = 38383; +const orderFilterDurationHours = 24; + +final orderEventsProvider = StreamProvider>((ref) { + final orderRepository = ref.watch(orderRepositoryProvider); + DateTime filterTime = DateTime.now().subtract(Duration(hours: orderFilterDurationHours)); + var filter = NostrFilter( + kinds: const [orderEventKind], + since: filterTime, + ); + orderRepository.subscribe(filter); + + return orderRepository.eventsStream; +}); \ No newline at end of file diff --git a/lib/providers/exchange_service_provider.dart b/lib/providers/exchange_service_provider.dart new file mode 100644 index 00000000..bb025653 --- /dev/null +++ b/lib/providers/exchange_service_provider.dart @@ -0,0 +1,22 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:mostro_mobile/services/exchange_service.dart'; +import 'package:mostro_mobile/services/yadio_exchange_service.dart'; + +final exchangeServiceProvider = Provider((ref) { + return YadioExchangeService(); +}); + +final exchangeRateProvider = StateNotifierProvider.family, String>((ref, currency) { + final exchangeService = ref.read(exchangeServiceProvider); + final notifier = ExchangeRateNotifier(exchangeService); + notifier.fetchExchangeRate(currency); + return notifier; +}); + +final currencyCodesProvider = FutureProvider>((ref) async { + final exchangeService = ref.read(exchangeServiceProvider); + + return await exchangeService.getCurrencyCodes(); +}); + +final selectedFiatCodeProvider = StateProvider((ref) => null); diff --git a/lib/providers/riverpod_providers.dart b/lib/providers/riverpod_providers.dart new file mode 100644 index 00000000..0896f50f --- /dev/null +++ b/lib/providers/riverpod_providers.dart @@ -0,0 +1,24 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:mostro_mobile/data/repositories/secure_storage_manager.dart'; +import 'package:mostro_mobile/presentation/home/bloc/home_bloc.dart'; +import 'package:mostro_mobile/services/mostro_service.dart'; +import 'package:mostro_mobile/services/nostr_service.dart'; + +final nostrServicerProvider = Provider((ref) { + return NostrService()..init(); +}); + +final homeBlocProvider = Provider((ref) { + return HomeBloc(); +}); + +final sessionManagerProvider = Provider((ref) { + return SecureStorageManager(); +}); + +final mostroServiceProvider = Provider((ref) { + final sessionStorage = ref.watch(sessionManagerProvider); + final nostrService = ref.watch(nostrServicerProvider); + return MostroService(nostrService, sessionStorage); +}); + diff --git a/lib/services/currency_input_formatter.dart b/lib/services/currency_input_formatter.dart new file mode 100644 index 00000000..699e508b --- /dev/null +++ b/lib/services/currency_input_formatter.dart @@ -0,0 +1,18 @@ +import 'package:flutter/services.dart'; + +class CurrencyInputFormatter extends TextInputFormatter { + @override + TextEditingValue formatEditUpdate( + TextEditingValue oldValue, + TextEditingValue newValue, + ) { + final text = newValue.text; + final regex = RegExp(r'^\d*\.?\d{0,2}$'); + + if (regex.hasMatch(text)) { + return newValue; + } else { + return oldValue; + } + } +} diff --git a/lib/services/exchange_service.dart b/lib/services/exchange_service.dart new file mode 100644 index 00000000..9a9875f9 --- /dev/null +++ b/lib/services/exchange_service.dart @@ -0,0 +1,72 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:http/http.dart' as http; + +class ExchangeRateNotifier extends StateNotifier> { + final ExchangeService exchangeService; + + ExchangeRateNotifier(this.exchangeService) + : super(const AsyncValue.loading()); + + Future fetchExchangeRate(String currency) async { + try { + state = const AsyncValue.loading(); + final rate = await exchangeService.getExchangeRate(currency, 'BTC'); + state = AsyncValue.data(rate); + } catch (error) { + state = AsyncValue.error(error, StackTrace.current); + } + } +} + +abstract class ExchangeService { + final String baseUrl; + final Duration timeout; + final Map defaultHeaders; + + ExchangeService( + this.baseUrl, { + this.timeout = const Duration(seconds: 30), + this.defaultHeaders = const {'Accept': 'application/json'}, + }) { + if (baseUrl.isEmpty) { + throw ArgumentError('baseUrl cannot be empty'); + } + if (!baseUrl.startsWith('http')) { + throw ArgumentError('baseUrl must start with http:// or https://'); + } + } + + Future getExchangeRate( + String fromCurrency, + String toCurrency, + ); + + Future> getRequest(String endpoint) async { + final url = Uri.parse('$baseUrl$endpoint'); + try { + final response = await http + .get(url, headers: defaultHeaders) + .timeout(timeout); + + if (response.statusCode == 200) { + return json.decode(response.body) as Map; + } + + throw HttpException( + 'Failed to load data: ${response.statusCode}', + uri: url, + ); + } on TimeoutException { + throw HttpException('Request timed out', uri: url); + } on FormatException catch (e) { + throw HttpException('Invalid response format: ${e.message}', uri: url); + } catch (e) { + throw HttpException('Request failed: $e', uri: url); + } + } + + Future> getCurrencyCodes(); +} \ No newline at end of file diff --git a/lib/services/mostro_service.dart b/lib/services/mostro_service.dart index 68ccf7c5..28916515 100644 --- a/lib/services/mostro_service.dart +++ b/lib/services/mostro_service.dart @@ -1,104 +1,172 @@ +import 'dart:collection'; import 'dart:convert'; +import 'package:dart_nostr/nostr/model/event/event.dart'; import 'package:dart_nostr/nostr/model/request/filter.dart'; import 'package:mostro_mobile/core/config.dart'; -import 'package:mostro_mobile/data/models/order_model.dart'; +import 'package:mostro_mobile/data/models/content.dart'; +import 'package:mostro_mobile/data/models/mostro_message.dart'; +import 'package:mostro_mobile/data/models/order.dart'; +import 'package:mostro_mobile/data/models/enums/action.dart'; +import 'package:mostro_mobile/data/models/session.dart'; +import 'package:mostro_mobile/data/repositories/mostro_repository.dart'; +import 'package:mostro_mobile/data/repositories/secure_storage_manager.dart'; import 'package:mostro_mobile/services/nostr_service.dart'; +const int mostroVersion = 1; + class MostroService { final NostrService _nostrService; + final SecureStorageManager _secureStorageManager; + final _mostroRepository = MostroRepository(); + + final _orders = HashMap(); + final _sessions = HashMap(); + + MostroService(this._nostrService, this._secureStorageManager); + + Stream subscribeToOrders(NostrFilter filter) { + return _nostrService.subscribeToEvents(filter); + } - MostroService(this._nostrService); + Future publishOrder(Order order) async { + final session = await _secureStorageManager.newSession(); - Future publishOrder(OrderModel order) async { final content = jsonEncode({ 'order': { - 'version': 1, - 'action': 'new-order', - 'content': { - 'order': order.toJson(), - }, + 'version': mostroVersion, + 'action': Action.newOrder.value, + 'content': order.toJson(), }, }); - final event = await _nostrService.createNIP59Event(content, Config.mostroPubKey); + + final event = await _nostrService.createNIP59Event( + content, Config.mostroPubKey, session.privateKey); + await _nostrService.publishEvent(event); + + final filter = NostrFilter(p: [session.publicKey]); + + return await subscribeToOrders(filter).asyncMap((event) async { + return await _nostrService.decryptNIP59Event(event, session.privateKey); + }).map((event) { + return MostroMessage.deserialized(event.content!); + }).first; } - Future cancelOrder(String orderId) async { + Future takeSellOrder(String orderId, {int? amount}) async { + final session = await _secureStorageManager.newSession(); + session.eventId = orderId; + final content = jsonEncode({ 'order': { - 'version': 1, + 'version': mostroVersion, 'id': orderId, - 'action': 'cancel', - 'content': null, + 'action': Action.takeSell.value, + 'content': amount != null ? {'amount': amount} : null, }, }); - final event = await _nostrService.createNIP59Event(content, Config.mostroPubKey); + final event = await _nostrService.createNIP59Event( + content, Config.mostroPubKey, session.privateKey); + await _nostrService.publishEvent(event); + + final filter = NostrFilter(p: [session.publicKey]); + + return await subscribeToOrders(filter).asyncMap((event) async { + return await _nostrService.decryptNIP59Event(event, session.privateKey); + }).map((event) { + return MostroMessage.deserialized(event.content!); + }).first; } - Future takeSellOrder(String orderId, {int? amount}) async { + Future> takeBuyOrder(String orderId, + {int? amount}) async { + final session = await _secureStorageManager.newSession(); + session.eventId = orderId; + final content = jsonEncode({ 'order': { - 'version': 1, + 'version': mostroVersion, 'id': orderId, - 'action': 'take-sell', + 'action': Action.takeBuy.value, 'content': amount != null ? {'amount': amount} : null, }, }); - final event = await _nostrService.createNIP59Event(content, Config.mostroPubKey); + final event = await _nostrService.createNIP59Event( + content, Config.mostroPubKey, session.privateKey); await _nostrService.publishEvent(event); + final filter = NostrFilter(p: [session.publicKey]); + + return await subscribeToOrders(filter).asyncMap((event) async { + return await _nostrService.decryptNIP59Event(event, session.privateKey); + }).map((event) { + return MostroMessage.deserialized(event.content!); + }).first; } - Future takeBuyOrder(String orderId, {int? amount}) async { + Future cancelOrder(String orderId) async { + final order = _mostroRepository.getOrder(orderId); + + if (order == null) { + throw Exception('Order not found for order ID: $orderId'); + } + + final session = await _secureStorageManager.loadSession(order!.requestId!); + + if (session == null) { + throw Exception('Session not found for order ID: $orderId'); + } + final content = jsonEncode({ 'order': { - 'version': 1, + 'version': mostroVersion, 'id': orderId, - 'action': 'take-buy', - 'content': amount != null ? {'amount': amount} : null, + 'action': Action.cancel, + 'content': null, }, }); - final event = await _nostrService.createNIP59Event(content, Config.mostroPubKey); + final event = await _nostrService.createNIP59Event( + content, Config.mostroPubKey, session.privateKey); await _nostrService.publishEvent(event); } - Stream subscribeToOrders() { - DateTime filterTime = DateTime.now().subtract(Duration(hours: 24)); - - var filter = NostrFilter( - kinds: const [38383], - since: filterTime, - ); - return _nostrService.subscribeToEvents(filter).map((event) { - // Convertir el evento Nostr a OrderModel - // Implementar la lógica de conversión aquí - return OrderModel.fromEventTags(event.tags!); - }); - } - Future sendFiatSent(String orderId) async { + final session = await _secureStorageManager.loadSession(orderId); + + if (session == null) { + throw Exception('Session not found for order ID: $orderId'); + } + final content = jsonEncode({ 'order': { - 'version': 1, + 'version': mostroVersion, 'id': orderId, - 'action': 'fiat-sent', + 'action': Action.fiatSent.value, 'content': null, }, }); - final event = await _nostrService.createNIP59Event(content, Config.mostroPubKey); + final event = await _nostrService.createNIP59Event( + content, Config.mostroPubKey, session.privateKey); await _nostrService.publishEvent(event); } Future releaseOrder(String orderId) async { + final session = await _secureStorageManager.loadSession(orderId); + + if (session == null) { + throw Exception('Session not found for order ID: $orderId'); + } + final content = jsonEncode({ 'order': { - 'version': 1, + 'version': mostroVersion, 'id': orderId, - 'action': 'release', + 'action': Action.release.value, 'content': null, }, }); - final event = await _nostrService.createNIP59Event(content, Config.mostroPubKey); + final event = await _nostrService.createNIP59Event( + content, Config.mostroPubKey, session.privateKey); await _nostrService.publishEvent(event); } -} \ No newline at end of file +} diff --git a/lib/services/nostr_service.dart b/lib/services/nostr_service.dart index 83765ede..225b20ae 100644 --- a/lib/services/nostr_service.dart +++ b/lib/services/nostr_service.dart @@ -80,39 +80,25 @@ class NostrService { return keyPair; } - Future getPrivateKey() async { - return await AuthUtils.getPrivateKey(); - } - String getMostroPubKey() { return Config.mostroPubKey; } Future createNIP59Event( - String content, String recipientPubKey) async { + String content, String recipientPubKey, String senderPrivateKey) async { if (!_isInitialized) { throw Exception('Nostr is not initialized. Call init() first.'); } - final senderPrivateKey = await getPrivateKey(); - if (senderPrivateKey == null) { - throw Exception('No private key found. Generate a key pair first.'); - } - return NostrUtils.createNIP59Event( content, recipientPubKey, senderPrivateKey); } - Future decryptNIP59Event(NostrEvent event) async { + Future decryptNIP59Event(NostrEvent event, String privateKey) async { if (!_isInitialized) { throw Exception('Nostr is not initialized. Call init() first.'); } - final privateKey = await getPrivateKey(); - if (privateKey == null) { - throw Exception('No private key found. Generate a key pair first.'); - } - return NostrUtils.decryptNIP59Event(event, privateKey); } } diff --git a/lib/services/yadio_exchange_service.dart b/lib/services/yadio_exchange_service.dart new file mode 100644 index 00000000..a0f96cb8 --- /dev/null +++ b/lib/services/yadio_exchange_service.dart @@ -0,0 +1,47 @@ +import 'exchange_service.dart'; + +class YadioExchangeService extends ExchangeService { + YadioExchangeService() : super('https://api.yadio.io/'); + + @override + Future getExchangeRate( + String fromCurrency, + String toCurrency, + ) async { + if (fromCurrency.isEmpty || toCurrency.isEmpty) { + throw ArgumentError('Currency codes cannot be empty'); + } + + final endpoint = 'rate/$fromCurrency/$toCurrency'; + try { + final data = await getRequest(endpoint); + + final rate = data['rate']; + if (rate == null) { + throw Exception('Rate not found for $fromCurrency to $toCurrency'); + } + + if (rate is! num) { + throw Exception('Invalid rate format received from API'); + } + return rate.toDouble(); + } catch (e) { + throw Exception('Failed to fetch exchange rate: $e'); + } + } + + @override + Future> getCurrencyCodes() async { + final endpoint = 'currencies'; + try { + final data = await getRequest(endpoint); + return Map.fromEntries( + data.entries.map((entry) { + return MapEntry(entry.key, entry.value?.toString() ?? ''); + }), + ); + } catch (e) { + throw Exception('Failed to fetch currency codes: $e'); + } + } +} diff --git a/linux/flutter/generated_plugin_registrant.cc b/linux/flutter/generated_plugin_registrant.cc index d0e7f797..e71a16d2 100644 --- a/linux/flutter/generated_plugin_registrant.cc +++ b/linux/flutter/generated_plugin_registrant.cc @@ -6,10 +6,6 @@ #include "generated_plugin_registrant.h" -#include void fl_register_plugins(FlPluginRegistry* registry) { - g_autoptr(FlPluginRegistrar) flutter_secure_storage_linux_registrar = - fl_plugin_registry_get_registrar_for_plugin(registry, "FlutterSecureStorageLinuxPlugin"); - flutter_secure_storage_linux_plugin_register_with_registrar(flutter_secure_storage_linux_registrar); } diff --git a/linux/flutter/generated_plugins.cmake b/linux/flutter/generated_plugins.cmake index b29e9ba0..2e1de87a 100644 --- a/linux/flutter/generated_plugins.cmake +++ b/linux/flutter/generated_plugins.cmake @@ -3,7 +3,6 @@ # list(APPEND FLUTTER_PLUGIN_LIST - flutter_secure_storage_linux ) list(APPEND FLUTTER_FFI_PLUGIN_LIST diff --git a/macos/Flutter/GeneratedPluginRegistrant.swift b/macos/Flutter/GeneratedPluginRegistrant.swift index 86135f19..307f9946 100644 --- a/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/macos/Flutter/GeneratedPluginRegistrant.swift @@ -5,13 +5,11 @@ import FlutterMacOS import Foundation -import flutter_secure_storage_macos import local_auth_darwin import path_provider_foundation import shared_preferences_foundation func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { - FlutterSecureStoragePlugin.register(with: registry.registrar(forPlugin: "FlutterSecureStoragePlugin")) FLALocalAuthPlugin.register(with: registry.registrar(forPlugin: "FLALocalAuthPlugin")) PathProviderPlugin.register(with: registry.registrar(forPlugin: "PathProviderPlugin")) SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin")) diff --git a/pubspec.lock b/pubspec.lock index 192a3802..b48ca7ed 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -1,22 +1,35 @@ # Generated by pub # See https://dart.dev/tools/pub/glossary#lockfile packages: - args: + _fe_analyzer_shared: dependency: transitive description: - name: args - sha256: bf9f5caeea8d8fe6721a9c358dd8a5c1947b27f1cfaa18b39c301273594919e6 + name: _fe_analyzer_shared + sha256: f256b0c0ba6c7577c15e2e4e114755640a875e885099367bf6e012b19314c834 url: "https://pub.dev" source: hosted - version: "2.6.0" - asn1lib: + version: "72.0.0" + _macros: + dependency: transitive + description: dart + source: sdk + version: "0.3.2" + analyzer: dependency: transitive description: - name: asn1lib - sha256: "6b151826fcc95ff246cd219a0bf4c753ea14f4081ad71c61939becf3aba27f70" + name: analyzer + sha256: b652861553cd3990d8ed361f7979dc6d7053a9ac8843fa73820ab68ce5410139 url: "https://pub.dev" source: hosted - version: "1.5.5" + version: "6.7.0" + args: + dependency: transitive + description: + name: args + sha256: bf9f5caeea8d8fe6721a9c358dd8a5c1947b27f1cfaa18b39c301273594919e6 + url: "https://pub.dev" + source: hosted + version: "2.6.0" async: dependency: transitive description: @@ -65,6 +78,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.0.6" + bitcoin_icons: + dependency: "direct main" + description: + name: bitcoin_icons + sha256: "6ee9e69d78683035c089ebc91c3c9de95f7694d7bdcfa7edbbd51d832876f9b3" + url: "https://pub.dev" + source: hosted + version: "0.0.4" bloc: dependency: "direct main" description: @@ -81,6 +102,30 @@ packages: url: "https://pub.dev" source: hosted version: "2.1.1" + build: + dependency: transitive + description: + name: build + sha256: "80184af8b6cb3e5c1c4ec6d8544d27711700bc3e6d2efad04238c7b5290889f0" + url: "https://pub.dev" + source: hosted + version: "2.4.1" + built_collection: + dependency: transitive + description: + name: built_collection + sha256: "376e3dd27b51ea877c28d525560790aee2e6fbb5f20e2f85d5081027d94e2100" + url: "https://pub.dev" + source: hosted + version: "5.1.1" + built_value: + dependency: transitive + description: + name: built_value + sha256: c7913a9737ee4007efedaffc968c049fd0f3d0e49109e778edc10de9426005cb + url: "https://pub.dev" + source: hosted + version: "8.9.2" characters: dependency: transitive description: @@ -97,8 +142,16 @@ packages: url: "https://pub.dev" source: hosted version: "1.1.1" - collection: + code_builder: dependency: transitive + description: + name: code_builder + sha256: "0ec10bf4a89e4c613960bf1e8b42c64127021740fb21640c29c909826a5eea3e" + url: "https://pub.dev" + source: hosted + version: "4.10.1" + collection: + dependency: "direct main" description: name: collection sha256: ee67cb0715911d28db6bf4af1026078bd6f0128b07a5f66fb2ed94ec6783c09a @@ -113,6 +166,14 @@ packages: url: "https://pub.dev" source: hosted version: "3.1.2" + coverage: + dependency: transitive + description: + name: coverage + sha256: "4b03e11f6d5b8f6e5bb5e9f7889a56fe6c5cbe942da5378ea4d4d7f73ef9dfe5" + url: "https://pub.dev" + source: hosted + version: "1.11.0" crypto: dependency: "direct main" description: @@ -121,14 +182,14 @@ packages: url: "https://pub.dev" source: hosted version: "3.0.6" - cryptography: - dependency: "direct main" + cryptography_plus: + dependency: transitive description: - name: cryptography - sha256: d146b76d33d94548cf035233fbc2f4338c1242fa119013bead807d033fc4ae05 + name: cryptography_plus + sha256: "34db787df4f4740a39474b6fb0a610aa6dc13a5b5b68754b4787a79939ac0454" url: "https://pub.dev" source: hosted - version: "2.7.0" + version: "2.7.1" cupertino_icons: dependency: "direct main" description: @@ -145,14 +206,22 @@ packages: url: "https://pub.dev" source: hosted version: "8.2.0" - encrypt: + dart_style: + dependency: transitive + description: + name: dart_style + sha256: "7856d364b589d1f08986e140938578ed36ed948581fbc3bc9aef1805039ac5ab" + url: "https://pub.dev" + source: hosted + version: "2.3.7" + elliptic: dependency: "direct main" description: - name: encrypt - sha256: "62d9aa4670cc2a8798bab89b39fc71b6dfbacf615de6cf5001fb39f7e4a996a2" + name: elliptic + sha256: "0c303d810603953a65dc39c4c542fb7538defd9e212403c54c266140819523b6" url: "https://pub.dev" source: hosted - version: "5.0.3" + version: "0.3.11" equatable: dependency: "direct main" description: @@ -181,10 +250,18 @@ packages: dependency: transitive description: name: file - sha256: a3b4f84adafef897088c160faf7dfffb7696046cb13ae90b508c2cbc95d3b8d4 + sha256: "5fc22d7c25582e38ad9a8515372cd9a93834027aacf1801cf01164dac0ffa08c" url: "https://pub.dev" source: hosted - version: "7.0.1" + version: "7.0.0" + fixnum: + dependency: transitive + description: + name: fixnum + sha256: b6dc7065e46c974bc7c5f143080a6764ec7a4be6da1285ececdc37be96de53be + url: "https://pub.dev" + source: hosted + version: "1.1.1" flutter: dependency: "direct main" description: flutter @@ -198,6 +275,19 @@ packages: url: "https://pub.dev" source: hosted version: "8.1.6" + flutter_driver: + dependency: transitive + description: flutter + source: sdk + version: "0.0.0" + flutter_intl: + dependency: "direct dev" + description: + name: flutter_intl + sha256: "17b138fab0477c7d17abd8ba67d294786eef8fe80342e58b6253aef7d0ca2bad" + url: "https://pub.dev" + source: hosted + version: "0.0.1" flutter_lints: dependency: "direct dev" description: @@ -206,6 +296,11 @@ packages: url: "https://pub.dev" source: hosted version: "5.0.0" + flutter_localizations: + dependency: "direct main" + description: flutter + source: sdk + version: "0.0.0" flutter_plugin_android_lifecycle: dependency: transitive description: @@ -214,62 +309,22 @@ packages: url: "https://pub.dev" source: hosted version: "2.0.23" - flutter_secure_storage: + flutter_riverpod: dependency: "direct main" description: - name: flutter_secure_storage - sha256: "165164745e6afb5c0e3e3fcc72a012fb9e58496fb26ffb92cf22e16a821e85d0" - url: "https://pub.dev" - source: hosted - version: "9.2.2" - flutter_secure_storage_linux: - dependency: transitive - description: - name: flutter_secure_storage_linux - sha256: "4d91bfc23047422cbcd73ac684bc169859ee766482517c22172c86596bf1464b" - url: "https://pub.dev" - source: hosted - version: "1.2.1" - flutter_secure_storage_macos: - dependency: transitive - description: - name: flutter_secure_storage_macos - sha256: "1693ab11121a5f925bbea0be725abfcfbbcf36c1e29e571f84a0c0f436147a81" - url: "https://pub.dev" - source: hosted - version: "3.1.2" - flutter_secure_storage_platform_interface: - dependency: transitive - description: - name: flutter_secure_storage_platform_interface - sha256: cf91ad32ce5adef6fba4d736a542baca9daf3beac4db2d04be350b87f69ac4a8 - url: "https://pub.dev" - source: hosted - version: "1.1.2" - flutter_secure_storage_web: - dependency: transitive - description: - name: flutter_secure_storage_web - sha256: f4ebff989b4f07b2656fb16b47852c0aab9fed9b4ec1c70103368337bc1886a9 + name: flutter_riverpod + sha256: "9532ee6db4a943a1ed8383072a2e3eeda041db5657cdf6d2acecf3c21ecbe7e1" url: "https://pub.dev" source: hosted - version: "1.2.1" - flutter_secure_storage_windows: - dependency: transitive - description: - name: flutter_secure_storage_windows - sha256: b20b07cb5ed4ed74fc567b78a72936203f587eba460af1df11281c9326cd3709 - url: "https://pub.dev" - source: hosted - version: "3.1.2" + version: "2.6.1" flutter_svg: dependency: transitive description: name: flutter_svg - sha256: "7b4ca6cf3304575fe9c8ec64813c8d02ee41d2afe60bcfe0678bcb5375d596a2" + sha256: "578bd8c508144fdaffd4f77b8ef2d8c523602275cd697cc3db284dbd762ef4ce" url: "https://pub.dev" source: hosted - version: "2.0.10+1" + version: "2.0.14" flutter_test: dependency: "direct dev" description: flutter @@ -280,6 +335,27 @@ packages: description: flutter source: sdk version: "0.0.0" + frontend_server_client: + dependency: transitive + description: + name: frontend_server_client + sha256: f64a0333a82f30b0cca061bc3d143813a486dc086b574bfb233b7c1372427694 + url: "https://pub.dev" + source: hosted + version: "4.0.0" + fuchsia_remote_debug_protocol: + dependency: transitive + description: flutter + source: sdk + version: "0.0.0" + glob: + dependency: transitive + description: + name: glob + sha256: "0e7014b3b7d4dac1ca4d6114f82bf1782ee86745b9b42a92c9289c23d8a0ab63" + url: "https://pub.dev" + source: hosted + version: "2.1.2" google_fonts: dependency: "direct main" description: @@ -304,22 +380,6 @@ packages: url: "https://pub.dev" source: hosted version: "0.2.0" - hive: - dependency: "direct main" - description: - name: hive - sha256: "8dcf6db979d7933da8217edcec84e9df1bdb4e4edc7fc77dbd5aa74356d6d941" - url: "https://pub.dev" - source: hosted - version: "2.2.3" - hive_flutter: - dependency: "direct main" - description: - name: hive_flutter - sha256: dca1da446b1d808a51689fb5d0c6c9510c0a2ba01e22805d492c73b68e33eecc - url: "https://pub.dev" - source: hosted - version: "1.1.0" http: dependency: "direct main" description: @@ -328,6 +388,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.2.2" + http_multi_server: + dependency: transitive + description: + name: http_multi_server + sha256: "97486f20f9c2f7be8f514851703d0119c3596d14ea63227af6f7a481ef2b2f8b" + url: "https://pub.dev" + source: hosted + version: "3.2.1" http_parser: dependency: transitive description: @@ -336,22 +404,35 @@ packages: url: "https://pub.dev" source: hosted version: "4.0.2" + integration_test: + dependency: "direct dev" + description: flutter + source: sdk + version: "0.0.0" intl: - dependency: transitive + dependency: "direct main" description: name: intl sha256: d6f56758b7d3014a48af9701c085700aac781a92a87a62b1333b46d8879661cf url: "https://pub.dev" source: hosted version: "0.19.0" + io: + dependency: transitive + description: + name: io + sha256: "2ec25704aba361659e10e3e5f5d672068d332fc8ac516421d483a11e5cbd061e" + url: "https://pub.dev" + source: hosted + version: "1.0.4" js: dependency: transitive description: name: js - sha256: f2c445dce49627136094980615a031419f7f3eb393237e4ecd97ac15dea343f3 + sha256: c1b2e9b5ea78c45e1a0788d29606ba27dc5f71f019f32ca5140f61ef071838cf url: "https://pub.dev" source: hosted - version: "0.6.7" + version: "0.7.1" leak_tracker: dependency: transitive description: @@ -432,6 +513,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.3.0" + macros: + dependency: transitive + description: + name: macros + sha256: "0acaed5d6b7eab89f63350bccd82119e6c602df0f391260d0e32b5e23db79536" + url: "https://pub.dev" + source: hosted + version: "0.1.2-main.4" matcher: dependency: transitive description: @@ -456,6 +545,22 @@ packages: url: "https://pub.dev" source: hosted version: "1.15.0" + mime: + dependency: transitive + description: + name: mime + sha256: "41a20518f0cb1256669420fdba0cd90d21561e560ac240f26ef8322e45bb7ed6" + url: "https://pub.dev" + source: hosted + version: "2.0.0" + mockito: + dependency: "direct dev" + description: + name: mockito + sha256: "6841eed20a7befac0ce07df8116c8b8233ed1f4486a7647c7fc5a02ae6163917" + url: "https://pub.dev" + source: hosted + version: "5.4.4" nested: dependency: transitive description: @@ -464,6 +569,31 @@ packages: url: "https://pub.dev" source: hosted version: "1.0.0" + nip44: + dependency: "direct main" + description: + path: "." + ref: master + resolved-ref: "73379b8b1332f1ee78a250055494e6b73e673ea2" + url: "https://github.com/chebizarro/dart-nip44.git" + source: git + version: "1.0.0" + node_preamble: + dependency: transitive + description: + name: node_preamble + sha256: "6e7eac89047ab8a8d26cf16127b5ed26de65209847630400f9aefd7cd5c730db" + url: "https://pub.dev" + source: hosted + version: "2.0.2" + package_config: + dependency: transitive + description: + name: package_config + sha256: "1c5b77ccc91e4823a5af61ee74e6b972db1ef98c2ff5a18d3161c982a55448bd" + url: "https://pub.dev" + source: hosted + version: "2.1.0" path: dependency: transitive description: @@ -476,10 +606,10 @@ packages: dependency: transitive description: name: path_parsing - sha256: "45f7d6bba1128761de5540f39d5ca000ea8a1f22f06b76b61094a60a2997bd0e" + sha256: "883402936929eac138ee0a45da5b0f2c80f89913e6dc3bf77eb65b84b409c6ca" url: "https://pub.dev" source: hosted - version: "1.0.2" + version: "1.1.0" path_provider: dependency: transitive description: @@ -540,10 +670,10 @@ packages: dependency: transitive description: name: platform - sha256: "5d6b1b0036a5f331ebc77c850ebc8506cbc1e9416c27e59b439f917a902a4984" + sha256: "9b71283fc13df574056616011fb138fd3b793ea47cc509c189a6c3fa5f8a1a65" url: "https://pub.dev" source: hosted - version: "3.1.6" + version: "3.1.5" plugin_platform_interface: dependency: transitive description: @@ -553,13 +683,29 @@ packages: source: hosted version: "2.1.8" pointycastle: - dependency: "direct main" + dependency: transitive description: name: pointycastle sha256: "4be0097fcf3fd3e8449e53730c631200ebc7b88016acecab2b0da2f0149222fe" url: "https://pub.dev" source: hosted version: "3.9.1" + pool: + dependency: transitive + description: + name: pool + sha256: "20fe868b6314b322ea036ba325e6fc0711a22948856475e2c2b6306e8ab39c2a" + url: "https://pub.dev" + source: hosted + version: "1.5.1" + process: + dependency: transitive + description: + name: process + sha256: "21e54fd2faf1b5bdd5102afd25012184a6793927648ea81eea80552ac9405b32" + url: "https://pub.dev" + source: hosted + version: "5.0.2" provider: dependency: transitive description: @@ -568,6 +714,14 @@ packages: url: "https://pub.dev" source: hosted version: "6.1.2" + pub_semver: + dependency: transitive + description: + name: pub_semver + sha256: "40d3ab1bbd474c4c2328c91e3a7df8c6dd629b79ece4c4bd04bee496a224fb0c" + url: "https://pub.dev" + source: hosted + version: "2.1.4" qr: dependency: transitive description: @@ -584,14 +738,22 @@ packages: url: "https://pub.dev" source: hosted version: "4.1.0" + riverpod: + dependency: transitive + description: + name: riverpod + sha256: "59062512288d3056b2321804332a13ffdd1bf16df70dcc8e506e411280a72959" + url: "https://pub.dev" + source: hosted + version: "2.6.1" shared_preferences: dependency: "direct main" description: name: shared_preferences - sha256: "746e5369a43170c25816cc472ee016d3a66bc13fcf430c0bc41ad7b4b2922051" + sha256: "95f9997ca1fb9799d494d0cb2a780fd7be075818d59f00c43832ed112b158a82" url: "https://pub.dev" source: hosted - version: "2.3.2" + version: "2.3.3" shared_preferences_android: dependency: transitive description: @@ -640,11 +802,67 @@ packages: url: "https://pub.dev" source: hosted version: "2.4.1" + shelf: + dependency: transitive + description: + name: shelf + sha256: ad29c505aee705f41a4d8963641f91ac4cee3c8fad5947e033390a7bd8180fa4 + url: "https://pub.dev" + source: hosted + version: "1.4.1" + shelf_packages_handler: + dependency: transitive + description: + name: shelf_packages_handler + sha256: "89f967eca29607c933ba9571d838be31d67f53f6e4ee15147d5dc2934fee1b1e" + url: "https://pub.dev" + source: hosted + version: "3.0.2" + shelf_static: + dependency: transitive + description: + name: shelf_static + sha256: c87c3875f91262785dade62d135760c2c69cb217ac759485334c5857ad89f6e3 + url: "https://pub.dev" + source: hosted + version: "1.1.3" + shelf_web_socket: + dependency: transitive + description: + name: shelf_web_socket + sha256: "073c147238594ecd0d193f3456a5fe91c4b0abbcc68bf5cd95b36c4e194ac611" + url: "https://pub.dev" + source: hosted + version: "2.0.0" sky_engine: dependency: transitive description: flutter source: sdk version: "0.0.99" + source_gen: + dependency: transitive + description: + name: source_gen + sha256: "14658ba5f669685cd3d63701d01b31ea748310f7ab854e471962670abcf57832" + url: "https://pub.dev" + source: hosted + version: "1.5.0" + source_map_stack_trace: + dependency: transitive + description: + name: source_map_stack_trace + sha256: c0713a43e323c3302c2abe2a1cc89aa057a387101ebd280371d6a6c9fa68516b + url: "https://pub.dev" + source: hosted + version: "2.1.2" + source_maps: + dependency: transitive + description: + name: source_maps + sha256: "708b3f6b97248e5781f493b765c3337db11c5d2c81c3094f10904bfa8004c703" + url: "https://pub.dev" + source: hosted + version: "0.10.12" source_span: dependency: transitive description: @@ -661,6 +879,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.11.1" + state_notifier: + dependency: transitive + description: + name: state_notifier + sha256: b8677376aa54f2d7c58280d5a007f9e8774f1968d1fb1c096adcb4792fba29bb + url: "https://pub.dev" + source: hosted + version: "1.0.0" stream_channel: dependency: transitive description: @@ -677,6 +903,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.2.0" + sync_http: + dependency: transitive + description: + name: sync_http + sha256: "7f0cd72eca000d2e026bcd6f990b81d0ca06022ef4e32fb257b30d3d1014a961" + url: "https://pub.dev" + source: hosted + version: "0.3.1" term_glyph: dependency: transitive description: @@ -685,6 +919,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.2.1" + test: + dependency: "direct dev" + description: + name: test + sha256: "7ee44229615f8f642b68120165ae4c2a75fe77ae2065b1e55ae4711f6cf0899e" + url: "https://pub.dev" + source: hosted + version: "1.25.7" test_api: dependency: transitive description: @@ -693,6 +935,14 @@ packages: url: "https://pub.dev" source: hosted version: "0.7.2" + test_core: + dependency: transitive + description: + name: test_core + sha256: "55ea5a652e38a1dfb32943a7973f3681a60f872f8c3a05a14664ad54ef9c6696" + url: "https://pub.dev" + source: hosted + version: "0.6.4" timeago: dependency: "direct main" description: @@ -713,26 +963,26 @@ packages: dependency: transitive description: name: vector_graphics - sha256: "32c3c684e02f9bc0afb0ae0aa653337a2fe022e8ab064bcd7ffda27a74e288e3" + sha256: "773c9522d66d523e1c7b25dfb95cc91c26a1e17b107039cfe147285e92de7878" url: "https://pub.dev" source: hosted - version: "1.1.11+1" + version: "1.1.14" vector_graphics_codec: dependency: transitive description: name: vector_graphics_codec - sha256: c86987475f162fadff579e7320c7ddda04cd2fdeffbe1129227a85d9ac9e03da + sha256: "2430b973a4ca3c4dbc9999b62b8c719a160100dcbae5c819bae0cacce32c9cdb" url: "https://pub.dev" source: hosted - version: "1.1.11+1" + version: "1.1.12" vector_graphics_compiler: dependency: transitive description: name: vector_graphics_compiler - sha256: "12faff3f73b1741a36ca7e31b292ddeb629af819ca9efe9953b70bd63fc8cd81" + sha256: ab9ff38fc771e9ee1139320adbe3d18a60327370c218c60752068ebee4b49ab1 url: "https://pub.dev" source: hosted - version: "1.1.11+1" + version: "1.1.15" vector_math: dependency: transitive description: @@ -749,6 +999,14 @@ packages: url: "https://pub.dev" source: hosted version: "14.2.5" + watcher: + dependency: transitive + description: + name: watcher + sha256: "3d2ad6751b3c16cf07c7fca317a1413b3f26530319181b37e3b9039b84fc01d8" + url: "https://pub.dev" + source: hosted + version: "1.1.0" web: dependency: transitive description: @@ -758,21 +1016,29 @@ packages: source: hosted version: "1.1.0" web_socket_channel: - dependency: "direct main" + dependency: transitive description: name: web_socket_channel sha256: d88238e5eac9a42bb43ca4e721edba3c08c6354d4a53063afaa568516217621b url: "https://pub.dev" source: hosted version: "2.4.0" - win32: + webdriver: + dependency: transitive + description: + name: webdriver + sha256: "003d7da9519e1e5f329422b36c4dcdf18d7d2978d1ba099ea4e45ba490ed845e" + url: "https://pub.dev" + source: hosted + version: "3.0.3" + webkit_inspection_protocol: dependency: transitive description: - name: win32 - sha256: "10169d3934549017f0ae278ccb07f828f9d6ea21573bab0fb77b0e1ef0fce454" + name: webkit_inspection_protocol + sha256: "87d3f2333bb240704cd3f1c6b5b7acd8a10e7f0bc28c28dcf14e782014f4a572" url: "https://pub.dev" source: hosted - version: "5.7.2" + version: "1.2.1" xdg_directories: dependency: transitive description: @@ -789,6 +1055,14 @@ packages: url: "https://pub.dev" source: hosted version: "6.5.0" + yaml: + dependency: transitive + description: + name: yaml + sha256: "75769501ea3489fca56601ff33454fe45507ea3bfb014161abc3b43ae25989d5" + url: "https://pub.dev" + source: hosted + version: "3.1.2" sdks: - dart: ">=3.5.3 <=3.9.9" + dart: ">=3.5.4 <=3.9.9" flutter: ">=3.24.0" diff --git a/pubspec.yaml b/pubspec.yaml index 70af18e4..ec6dd4db 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -31,31 +31,34 @@ dependencies: flutter: sdk: flutter - - # The following adds the Cupertino Icons font to your application. - # Use with the CupertinoIcons class for iOS style icons. cupertino_icons: ^1.0.8 http: ^1.2.2 dart_nostr: ^8.2.0 bloc: ^8.1.4 flutter_bloc: ^8.1.6 - flutter_secure_storage: ^9.2.2 qr_flutter: ^4.0.0 heroicons: ^0.10.0 - cryptography: ^2.7.0 - hive: ^2.2.3 crypto: ^3.0.5 - hive_flutter: ^1.1.0 - pointycastle: ^3.9.1 convert: ^3.1.1 shared_preferences: ^2.3.2 - web_socket_channel: ^2.4.0 equatable: ^2.0.5 - encrypt: ^5.0.3 logging: ^1.2.0 local_auth: ^2.3.0 google_fonts: ^6.2.1 timeago: ^3.7.0 + flutter_riverpod: ^2.6.1 + bitcoin_icons: ^0.0.4 + collection: ^1.18.0 + elliptic: ^0.3.11 + + nip44: + git: + url: https://github.com/chebizarro/dart-nip44.git + ref: master + intl: ^0.19.0 + + flutter_localizations: + sdk: flutter dev_dependencies: flutter_test: @@ -67,12 +70,17 @@ dev_dependencies: # package. See that file for information about deactivating specific lint # rules and activating additional ones. flutter_lints: ^5.0.0 + test: ^1.25.7 + mockito: ^5.4.4 + integration_test: + sdk: flutter + flutter_intl: ^0.0.1 # For information on the generic Dart part of this file, see the # following page: https://dart.dev/tools/pub/pubspec - # The following section is specific to Flutter packages. flutter: + generate: true # The following line ensures that the Material Icons font is # included with your application, so that you can use the icons in @@ -86,10 +94,8 @@ flutter: # An image asset can refer to one or more resolution-specific "variants", see # https://flutter.dev/to/resolution-aware-images - # For details regarding adding assets from package dependencies, see # https://flutter.dev/to/asset-from-package - # To add custom fonts to your application, add a fonts section here, # in this "flutter" section. Each entry in this list should have a # "family" key with the font family name, and a "fonts" key with a @@ -103,7 +109,8 @@ flutter: - asset: assets/fonts/RobotoCondensed-Medium.ttf weight: 500 - asset: assets/fonts/RobotoCondensed-Regular.ttf - weight: 400 - # + weight: 400 # For details regarding fonts from package dependencies, # see https://flutter.dev/to/font-from-package +flutter_intl: + enabled: true diff --git a/test/models/order_test.dart b/test/models/order_test.dart new file mode 100644 index 00000000..ee7a5432 --- /dev/null +++ b/test/models/order_test.dart @@ -0,0 +1,51 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:mostro_mobile/data/models/enums/order_type.dart'; +import 'package:mostro_mobile/data/models/enums/status.dart'; +import 'package:mostro_mobile/data/models/order.dart'; +import 'package:test/test.dart'; + +Future> loadJson(String path) async { + final file = File(path); + final contents = await file.readAsString(); + return jsonDecode(contents); +} + +void main() { + group('Order Tests', () { + test('Create new Order with default values', () { + final order = Order( + kind: OrderType.sell, + fiatCode: 'VES', + fiatAmount: 100, + paymentMethod: 'face to face', + premium: 1, + ); + + expect(order.status, equals(Status.pending)); + expect(order.amount, equals(0)); + expect(order.fiatAmount, equals(100)); + }); + }); + + group('Order Tests with JSON', () { + test('Parse new sell order from JSON file', () async { + // Load JSON data + final jsonData = await loadJson('test/examples/new_sell_order.json'); + + // Parse JSON to model + final orderData = jsonData['order']['order']['content']['order']; + final order = Order.fromJson(orderData); + + // Validate model properties + expect(order.kind, equals(OrderType.sell)); + expect(order.status, equals(Status.pending)); + expect(order.amount, equals(0)); + expect(order.fiatCode, equals('VES')); + expect(order.fiatAmount, equals(100)); + expect(order.paymentMethod, equals('face to face')); + expect(order.premium, equals(1)); + }); + }); +} diff --git a/windows/flutter/generated_plugin_registrant.cc b/windows/flutter/generated_plugin_registrant.cc index 011734da..7407dddd 100644 --- a/windows/flutter/generated_plugin_registrant.cc +++ b/windows/flutter/generated_plugin_registrant.cc @@ -6,12 +6,9 @@ #include "generated_plugin_registrant.h" -#include #include void RegisterPlugins(flutter::PluginRegistry* registry) { - FlutterSecureStorageWindowsPluginRegisterWithRegistrar( - registry->GetRegistrarForPlugin("FlutterSecureStorageWindowsPlugin")); LocalAuthPluginRegisterWithRegistrar( registry->GetRegistrarForPlugin("LocalAuthPlugin")); } diff --git a/windows/flutter/generated_plugins.cmake b/windows/flutter/generated_plugins.cmake index 11485fce..ef187dca 100644 --- a/windows/flutter/generated_plugins.cmake +++ b/windows/flutter/generated_plugins.cmake @@ -3,7 +3,6 @@ # list(APPEND FLUTTER_PLUGIN_LIST - flutter_secure_storage_windows local_auth_windows )