diff --git a/assets/data/payment_methods.json b/assets/data/payment_methods.json new file mode 100644 index 00000000..4a22d88c --- /dev/null +++ b/assets/data/payment_methods.json @@ -0,0 +1,31 @@ +{ + "ARS": ["Mercado Pago", "MODO", "CVU", "Belo", "Lemon", "CBU", "Efectivo"], + "AUD": ["PayID", "Alipay", "Cash deposit", "Revolut", "Cash"], + "BOB": ["QR", "Transferencia Bancaria", "Efectivo"], + "BRL": ["PIX", "TED", "PicPay", "Depósito", "Cash"], + "CAD": ["Interac e-Transfer", "Any national bank", "Wise", "Revolut", "Cash"], + "CHF": ["TWINT", "Cash"], + "CLP": ["MACH", "Cuenta RUT", "Mercado Pago", "Transferencia bancaria", "Efectivo"], + "CRC": ["Bank transfer (IBAN)", "SINPE Móvil", "Cash"], + "COP": ["Nequi", "Daviplata", "PSE", "Llaves BRE-B", "Transferencia bancaria", "Efectivo"], + "CUP": ["Transfermovil", "EnZona", "Tarjeta Clásica", "Saldo móvil", "MiTransfer", "Efectivo"], + "EUR": ["Revolut", "HalCash", "Bank Transfer", "Wise", "SEPA instant", "Bizum", "Payoneer", "Cash"], + "GBP": ["Revolut", "Monzo", "Wise", "Any national bank", "Bank Transfer", "Cash"], + "JPY": ["PayPay", "Bank Transfer", "Cash"], + "KES": ["M-PESA", "Equity Bank", "NCBA Bank", "Airtel Money", "Co-operative Bank", "Standard Chartered", "Ecobank", "GTBank", "Pochi la Biashara", "Cash"], + "MLC": ["Transfermovil", "EnZona"], + "MWK": ["Airtel Money", "TNM Mpamba", "Bank Transfer", "Cash"], + "MXN": ["SPEI", "CoDi", "Retiro Cajero BBVA", "Efectivo"], + "MZN": ["M-Pesa", "e-Mola", "Millennium bim", "BCI", "Cash"], + "NGN": ["OPay", "PalmPay", "Moniepoint", "GTBank", "Access Bank", "UBA", "Zenith Bank", "Bank Transfer", "Cash"], + "PEN": ["Yape", "Plin", "QR Yape", "Transferencia bancaria", "Cash"], + "PHP": ["Any national bank", "GCash", "Cash"], + "PYG": ["SIPAP", "Transferencia bancaria", "Efectivo"], + "TZS": ["M-Pesa", "Mixx by Yas (Tigo Pesa)", "Airtel Money", "Halopesa", "CRDB Bank", "Cash"], + "UGX": ["MTN MoMo", "Airtel Money", "Chipper Cash", "Cash"], + "USD": ["Cash App", "Venmo", "Zelle", "PayPal", "Wise", "Payoneer", "Strike", "Revolut", "N1CO", "Transfer365", "Cash"], + "VES": ["Pago Móvil", "Binance P2P", "Transferencia bancaria", "Efectivo"], + "ZAR": ["Capitec", "FNB", "Absa", "Nedbank", "Standard Bank", "FNB eWallet", "MTN MoMo", "Cash"], + "ZMW": ["Airtel Money", "MTN MoMo", "Zamtel Kwacha", "FNB eWallet", "Stanbic Bank", "SPENN", "Cash"], + "default": ["Bank Transfer", "Cash in person"] +} diff --git a/lib/features/order/providers/payment_methods_provider.dart b/lib/features/order/providers/payment_methods_provider.dart new file mode 100644 index 00000000..6dcdab37 --- /dev/null +++ b/lib/features/order/providers/payment_methods_provider.dart @@ -0,0 +1,36 @@ +import 'dart:convert'; + +import 'package:flutter/services.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +/// The default payment methods used when a currency has no specific list. +const _fallbackMethods = ['Bank Transfer', 'Cash in person']; + +/// Loads the currency → payment-methods map from the bundled asset once. +final paymentMethodsDataProvider = + FutureProvider>>((ref) async { + final raw = + await rootBundle.loadString('assets/data/payment_methods.json'); + final decoded = jsonDecode(raw) as Map; + return decoded.map( + (code, methods) => MapEntry( + code, + (methods as List).cast(), + ), + ); +}); + +/// The suggested payment methods for [currencyCode], falling back to the +/// `default` list (and then a hardcoded fallback) for unknown currencies. +/// +/// Returns an empty list while the asset is still loading so the section can +/// render its custom field without flashing placeholder chips. +final paymentMethodsForCurrencyProvider = + Provider.family, String>((ref, currencyCode) { + final data = ref.watch(paymentMethodsDataProvider); + return data.maybeWhen( + data: (map) => + map[currencyCode] ?? map['default'] ?? _fallbackMethods, + orElse: () => const [], + ); +}); diff --git a/lib/features/order/widgets/payment_method_section.dart b/lib/features/order/widgets/payment_method_section.dart index 7c8a1b48..bcb0623c 100644 --- a/lib/features/order/widgets/payment_method_section.dart +++ b/lib/features/order/widgets/payment_method_section.dart @@ -2,22 +2,10 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:mostro/core/app_theme.dart'; +import 'package:mostro/features/order/providers/payment_methods_provider.dart'; +import 'package:mostro/features/order/widgets/currency_section.dart'; import 'package:mostro/l10n/app_localizations.dart'; -/// Common payment methods available for selection. -const _commonMethods = [ - 'Mercado Pago', - 'Bank Transfer', - 'Pix', - 'Zelle', - 'Wise', - 'SEPA', - 'Revolut', - 'Cash', - 'PayPal', - 'Nequi', -]; - /// Selected payment methods for the create-order form. final selectedPaymentMethodsProvider = StateProvider>((_) => []); @@ -57,6 +45,20 @@ class _PaymentMethodSectionState extends ConsumerState { final colors = theme.extension(); final green = colors?.mostroGreen ?? const Color(0xFF8CC63F); final inputBg = colors?.backgroundInput ?? const Color(0xFF252A3A); + // When the currency changes, drop any selected methods that are not valid + // for the new currency (the custom free-text entry is left untouched). + ref.listen(selectedFiatCodeProvider, (_, next) { + // Don't prune while the asset is still loading: the provider returns an + // empty list during load, which would wipe every selection. + if (!ref.read(paymentMethodsDataProvider).hasValue) return; + final valid = ref.read(paymentMethodsForCurrencyProvider(next)).toSet(); + final current = ref.read(selectedPaymentMethodsProvider); + final pruned = current.where(valid.contains).toList(); + if (pruned.length != current.length) { + ref.read(selectedPaymentMethodsProvider.notifier).state = pruned; + } + }); + final selected = ref.watch(selectedPaymentMethodsProvider); final custom = ref.watch(customPaymentMethodProvider); final l10n = AppLocalizations.of(context); @@ -146,12 +148,15 @@ class _PaymentMethodSectionState extends ConsumerState { void _showMethodPicker(BuildContext context) { final selected = ref.read(selectedPaymentMethodsProvider); + final fiatCode = ref.read(selectedFiatCodeProvider); + final methods = ref.read(paymentMethodsForCurrencyProvider(fiatCode)); showDialog( context: context, builder: (dialogContext) => _MethodPickerDialog( selected: selected, - onDone: (methods) { - ref.read(selectedPaymentMethodsProvider.notifier).state = methods; + methods: methods, + onDone: (chosen) { + ref.read(selectedPaymentMethodsProvider.notifier).state = chosen; Navigator.pop(dialogContext); }, ), @@ -162,10 +167,12 @@ class _PaymentMethodSectionState extends ConsumerState { class _MethodPickerDialog extends StatefulWidget { const _MethodPickerDialog({ required this.selected, + required this.methods, required this.onDone, }); final List selected; + final List methods; final ValueChanged> onDone; @override @@ -202,7 +209,7 @@ class _MethodPickerDialogState extends State<_MethodPickerDialog> { Wrap( spacing: AppSpacing.sm, runSpacing: AppSpacing.xs, - children: _commonMethods.map((method) { + children: widget.methods.map((method) { final isSelected = _selected.contains(method); return FilterChip( label: Text(method, style: const TextStyle(fontSize: 12)), diff --git a/pubspec.yaml b/pubspec.yaml index c5552f0f..0bfc6dde 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -101,4 +101,5 @@ flutter: assets: - assets/data/fiat.json + - assets/data/payment_methods.json - assets/images/ \ No newline at end of file diff --git a/test/features/order/providers/payment_methods_provider_test.dart b/test/features/order/providers/payment_methods_provider_test.dart new file mode 100644 index 00000000..37f07f46 --- /dev/null +++ b/test/features/order/providers/payment_methods_provider_test.dart @@ -0,0 +1,65 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mostro/features/order/providers/payment_methods_provider.dart'; + +/// In-memory data used to exercise the currency provider without touching +/// rootBundle (which isn't wired for plain unit tests). +const _fixture = >{ + 'ARS': ['Mercado Pago', 'CVU'], + 'default': ['Bank Transfer', 'Cash in person'], +}; + +ProviderContainer _containerWith(Map> data) { + final c = ProviderContainer(overrides: [ + paymentMethodsDataProvider.overrideWith((ref) async => data), + ]); + addTearDown(c.dispose); + return c; +} + +void main() { + group('paymentMethodsForCurrencyProvider', () { + test('returns the currency-specific list for a known currency', () async { + final c = _containerWith(_fixture); + await c.read(paymentMethodsDataProvider.future); + expect(c.read(paymentMethodsForCurrencyProvider('ARS')), + ['Mercado Pago', 'CVU']); + }); + + test('falls back to the default list for an unknown currency', () async { + final c = _containerWith(_fixture); + await c.read(paymentMethodsDataProvider.future); + expect(c.read(paymentMethodsForCurrencyProvider('XXX')), + ['Bank Transfer', 'Cash in person']); + }); + + test('returns an empty list while the asset is still loading', () { + final c = _containerWith(_fixture); + // Not awaited: the future is still pending, so the provider must yield []. + expect(c.read(paymentMethodsForCurrencyProvider('ARS')), isEmpty); + }); + }); + + group('shipped payment_methods.json contract', () { + late Map shipped; + setUpAll(() { + shipped = jsonDecode( + File('assets/data/payment_methods.json').readAsStringSync(), + ) as Map; + }); + + test('ships a default fallback plus many currencies', () { + expect(shipped.containsKey('default'), isTrue); + expect(shipped.length, greaterThan(20)); + }); + + test('known currencies carry their expected local methods', () { + expect((shipped['ARS'] as List).cast(), contains('Mercado Pago')); + expect((shipped['MWK'] as List).cast(), contains('Airtel Money')); + expect((shipped['KES'] as List).cast(), contains('M-PESA')); + }); + }); +}